From b8e6bbd4610b50169f1d19a0929d0db68aa82ad7 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 19 Nov 2024 09:51:55 +0100 Subject: [PATCH 01/61] [Infra] Use callback for logger.trace calls (#199805) Use a callback for logger.trace() so big objects only get stringified when trace logging is enabled. --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/alerting/inventory_metric_threshold/lib/get_data.ts | 4 ++-- .../lib/alerting/metric_threshold/lib/check_missing_group.ts | 4 ++-- .../server/lib/alerting/metric_threshold/lib/get_data.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/observability_solution/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts b/x-pack/plugins/observability_solution/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts index 207f2fcb7cb27..e911440ce5aa2 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts @@ -159,9 +159,9 @@ export const getData = async ( customMetric, fieldsExisted ); - logger.trace(`Request: ${JSON.stringify(request)}`); + logger.trace(() => `Request: ${JSON.stringify(request)}`); const body = await esClient.search(request); - logger.trace(`Response: ${JSON.stringify(body)}`); + logger.trace(() => `Response: ${JSON.stringify(body)}`); if (body.aggregations) { return handleResponse(body.aggregations, previousNodes); } diff --git a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/check_missing_group.ts b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/check_missing_group.ts index f5e2a19cb70e9..d50c11710db76 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/check_missing_group.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/check_missing_group.ts @@ -57,9 +57,9 @@ export const checkMissingGroups = async ( ]; }); - logger.trace(`Request: ${JSON.stringify({ searches })}`); + logger.trace(() => `Request: ${JSON.stringify({ searches })}`); const response = await esClient.msearch({ searches }); - logger.trace(`Response: ${JSON.stringify(response)}`); + logger.trace(() => `Response: ${JSON.stringify(response)}`); const verifiedMissingGroups = response.responses .map((resp, index) => { diff --git a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/get_data.ts b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/get_data.ts index d2afb40cecf50..e30edbeac9360 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/get_data.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/lib/get_data.ts @@ -277,10 +277,10 @@ export const getData = async ( fieldsExisted ), }; - logger.trace(`Request: ${JSON.stringify(request)}`); + logger.trace(() => `Request: ${JSON.stringify(request)}`); const body = await esClient.search(request); const { aggregations, _shards } = body; - logger.trace(`Response: ${JSON.stringify(body)}`); + logger.trace(() => `Response: ${JSON.stringify(body)}`); if (aggregations) { return handleResponse(aggregations, previousResults, _shards.successful); } else if (_shards.successful) { From 742ae9fd2a255d5ba15100d644e7de3540e28f60 Mon Sep 17 00:00:00 2001 From: Jeramy Soucy Date: Tue, 19 Nov 2024 09:54:40 +0100 Subject: [PATCH 02/61] Surface Kibana security route deprecations in Upgrade Assistant (#199656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #197389 ## Summary Uses the `deprecated` route configuration option on all Kibana Security "v1" endpoints. This will surface deprecation information in the Upgrade Assistant. ## Related PRs - https://github.com/elastic/kibana/pull/50695 - `7.6.0`, deprecated - `/api/security/v1/me` - `/api/security/v1/logout` - `/api/security/v1/oidc/implicit` - `/api/security/v1/oidc` (POST) - https://github.com/elastic/kibana/pull/53886 - `7.6.0`, deprecated `/api/security/v1/oidc` (GET) - https://github.com/elastic/kibana/pull/47929 - `8.0.0`, dropped `/api/security/v1/saml` (`breaking` release note) - https://github.com/elastic/kibana/pull/106665 - restored `/api/security/v1/saml` but warned as deprecated (no release note) ## Testing 1. Start ES & Kibana in trial license mode 2. Make several calls to one or more of the deprecated endpoints 3. Navigate to `Stack Management`->`Upgrade Assistant` 4. Click on Kibana warnings Screenshot 2024-11-18 at 10 01 10 AM 5. Confirm the called endpoints are displayed as warnings in the Upgrade Assistant Screenshot 2024-11-18 at 9 59 34 AM ## Previous release notes ### v7.6.0 https://www.elastic.co/guide/en/kibana/7.6/release-notes-7.6.0.html#deprecation-7.6.0 https://www.elastic.co/guide/en/kibana/7.6/breaking-changes-7.6.html The deprecations are not listed in the release notes or breaking changes notes. ### v8.0.0 https://www.elastic.co/guide/en/kibana/current/release-notes-8.0.0-alpha1.html#rest-api-changes SAML endpoint deprecation only appears in the 8.0.0-alpha1 release notes, and was reverted in 8.0.0-alpha2 # Release note See `docs/upgrade-notes.asciidoc` in file changes # Follow-up A follow-up PR must be created to create and backfill the docLinks. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- docs/upgrade-notes.asciidoc | 38 ++++++---- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + x-pack/plugins/security/server/plugin.ts | 1 + .../server/routes/authentication/common.ts | 43 +++++++++-- .../server/routes/authentication/oidc.ts | 71 ++++++++++++++++--- .../server/routes/authentication/saml.ts | 23 +++++- .../security/server/routes/index.mock.ts | 3 + .../plugins/security/server/routes/index.ts | 3 +- x-pack/plugins/security/tsconfig.json | 1 + 10 files changed, 157 insertions(+), 28 deletions(-) diff --git a/docs/upgrade-notes.asciidoc b/docs/upgrade-notes.asciidoc index c2d866f90eed3..4d4208b2253f7 100644 --- a/docs/upgrade-notes.asciidoc +++ b/docs/upgrade-notes.asciidoc @@ -49,6 +49,32 @@ For Elastic Security release information, refer to {security-guide}/release-note [float] ==== Kibana APIs +[discrete] +[[breaking-199656]] +.Removed all security v1 endpoints (9.0.0) +[%collapsible] +==== +*Details* + +All `v1` Kibana security HTTP endpoints have been removed. + +`GET /api/security/v1/logout` has been replaced by `GET /api/security/logout` +`GET /api/security/v1/oidc/implicit` has been replaced by `GET /api/security/oidc/implicit` +`GET /api/security/v1/oidc` has been replaced by GET `/api/security/oidc/callback` +`POST /api/security/v1/oidc` has been replaced by POST `/api/security/oidc/initiate_login` +`POST /api/security/v1/saml` has been replaced by POST `/api/security/saml/callback` +`GET /api/security/v1/me` has been removed with no replacement. + +For more information, refer to {kibana-pull}199656[#199656]. + +*Impact* + +Any HTTP API calls to the `v1` Kibana security endpoints will fail with a 404 status code starting from version 9.0.0. +Third party OIDC and SAML identity providers configured with `v1` endpoints will no longer work. + +*Action* + +Update any OIDC and SAML identity providers to reference the corresponding replacement endpoint listed above. +Remove references to the `/api/security/v1/me` endpoint from any automations, applications, tooling, and scripts. +==== + [discrete] [[breaking-193792]] .Access to all internal APIs is blocked (9.0.0) @@ -814,18 +840,6 @@ The legacy audit logger has been removed. For more information, refer to {kibana Audit logs will be written to the default location in the new ECS format. To change the output file, filter events, and more, use the <>. ==== -[discrete] -[[breaking-47929]] -.[Security] Removed `/api/security/v1/saml` route. (8.0) -[%collapsible] -==== -*Details* + -The `/api/security/v1/saml` route has been removed and is reflected in the kibana.yml `server.xsrf.whitelist` setting, {es}, and the Identity Provider SAML settings. For more information, refer to {kibana-pull}47929[#47929] - -*Impact* + -Use the `/api/security/saml/callback` route, or wait to upgrade to 8.0.0-alpha2 when the `/api/security/saml/callback` route breaking change is reverted. -==== - [discrete] [[breaking-41700]] .[Security] Legacy browsers rejected by default. (8.0) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 52a3e7b7427e7..c7d714ebfbbb7 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -717,6 +717,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D mappingRoles: `${ELASTICSEARCH_DOCS}mapping-roles.html`, mappingRolesFieldRules: `${ELASTICSEARCH_DOCS}role-mapping-resources.html#mapping-roles-rule-field`, runAsPrivilege: `${ELASTICSEARCH_DOCS}security-privileges.html#_run_as_privilege`, + deprecatedV1Endpoints: `${KIBANA_DOCS}breaking-changes-summary.html#breaking-199656`, }, spaces: { kibanaLegacyUrlAliases: `${KIBANA_DOCS}legacy-url-aliases.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index d3ece5b61daa1..ac0f66d83b705 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -505,6 +505,7 @@ export interface DocLinks { mappingRoles: string; mappingRolesFieldRules: string; runAsPrivilege: string; + deprecatedV1Endpoints: string; }>; readonly spaces: Readonly<{ kibanaLegacyUrlAliases: string; diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 3007973d59b47..afd21a83712ae 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -338,6 +338,7 @@ export class SecurityPlugin getUserProfileService: this.getUserProfileService, analyticsService: this.analyticsService.setup({ analytics: core.analytics }), buildFlavor: this.initializerContext.env.packageInfo.buildFlavor, + docLinks: core.docLinks, }); return Object.freeze({ diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index 0c91a6c7f3858..4ee2e57a33517 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -7,6 +7,7 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; import { parseNextURL } from '@kbn/std'; import type { RouteDefinitionParams } from '..'; @@ -33,6 +34,7 @@ export function defineCommonRoutes({ license, logger, buildFlavor, + docLinks, }: RouteDefinitionParams) { // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. // For a serverless build, do not register deprecated versioned routes @@ -40,6 +42,7 @@ export function defineCommonRoutes({ '/api/security/logout', ...(buildFlavor !== 'serverless' ? ['/api/security/v1/logout'] : []), ]) { + const isDeprecated = path === '/api/security/v1/logout'; router.get( { path, @@ -57,13 +60,29 @@ export function defineCommonRoutes({ excludeFromOAS: true, authRequired: false, tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW], + ...(isDeprecated && { + deprecated: { + documentationUrl: docLinks.links.security.deprecatedV1Endpoints, + severity: 'warning', + message: i18n.translate('xpack.security.deprecations.logoutRouteMessage', { + defaultMessage: + 'The "{path}" URL is deprecated and will be removed in the next major version. Use "/api/security/logout" instead.', + values: { path }, + }), + reason: { + type: 'migrate', + newApiMethod: 'GET', + newApiPath: '/api/security/logout', + }, + }, + }), }, }, async (context, request, response) => { const serverBasePath = basePath.serverBasePath; - if (path === '/api/security/v1/logout') { + if (isDeprecated) { logger.warn( - `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/logout" URL instead.`, + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version. Use "${serverBasePath}/api/security/logout" URL instead.`, { tags: ['deprecation'] } ); } @@ -96,7 +115,7 @@ export function defineCommonRoutes({ '/internal/security/me', ...(buildFlavor !== 'serverless' ? ['/api/security/v1/me'] : []), ]) { - const deprecated = path === '/api/security/v1/me'; + const isDeprecated = path === '/api/security/v1/me'; router.get( { path, @@ -107,10 +126,24 @@ export function defineCommonRoutes({ }, }, validate: false, - options: { access: deprecated ? 'public' : 'internal' }, + options: { + access: isDeprecated ? 'public' : 'internal', + ...(isDeprecated && { + deprecated: { + documentationUrl: docLinks.links.security.deprecatedV1Endpoints, + severity: 'warning', + message: i18n.translate('xpack.security.deprecations.meRouteMessage', { + defaultMessage: + 'The "{path}" endpoint is deprecated and will be removed in the next major version.', + values: { path }, + }), + reason: { type: 'remove' }, + }, + }), + }, }, createLicensedRouteHandler(async (context, request, response) => { - if (deprecated) { + if (isDeprecated) { logger.warn( `The "${basePath.serverBasePath}${path}" endpoint is deprecated and will be removed in the next major version.`, { tags: ['deprecation'] } diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index bb1ed6959e690..d1d31f4c49a69 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -25,9 +25,11 @@ export function defineOIDCRoutes({ logger, getAuthenticationService, basePath, + docLinks, }: RouteDefinitionParams) { // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. for (const path of ['/api/security/oidc/implicit', '/api/security/v1/oidc/implicit']) { + const isDeprecated = path === '/api/security/v1/oidc/implicit'; /** * The route should be configured as a redirect URI in OP when OpenID Connect implicit flow * is used, so that we can extract authentication response from URL fragment and send it to @@ -37,13 +39,32 @@ export function defineOIDCRoutes({ { path, validate: false, - options: { authRequired: false, excludeFromOAS: true }, + options: { + authRequired: false, + excludeFromOAS: true, + ...(isDeprecated && { + deprecated: { + documentationUrl: docLinks.links.security.deprecatedV1Endpoints, + severity: 'warning', + message: i18n.translate('xpack.security.deprecations.oidcImplicitRouteMessage', { + defaultMessage: + 'The "{path}" URL is deprecated and will be removed in the next major version. Use "/api/security/oidc/implicit" instead.', + values: { path }, + }), + reason: { + type: 'migrate', + newApiMethod: 'GET', + newApiPath: '/api/security/oidc/implicit', + }, + }, + }), + }, }, (context, request, response) => { const serverBasePath = basePath.serverBasePath; - if (path === '/api/security/v1/oidc/implicit') { + if (isDeprecated) { logger.warn( - `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/implicit" URL instead.`, + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version. Use "${serverBasePath}/api/security/oidc/implicit" URL instead.`, { tags: ['deprecation'] } ); } @@ -84,6 +105,7 @@ export function defineOIDCRoutes({ // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. for (const path of ['/api/security/oidc/callback', '/api/security/v1/oidc']) { + const isDeprecated = path === '/api/security/v1/oidc'; router.get( { path, @@ -117,6 +139,22 @@ export function defineOIDCRoutes({ excludeFromOAS: true, authRequired: false, tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW], + ...(isDeprecated && { + deprecated: { + documentationUrl: docLinks.links.security.deprecatedV1Endpoints, + severity: 'warning', + message: i18n.translate('xpack.security.deprecations.oidcCallbackRouteMessage', { + defaultMessage: + 'The "{path}" URL is deprecated and will be removed in the next major version. Use "/api/security/oidc/callback" instead.', + values: { path }, + }), + reason: { + type: 'migrate', + newApiMethod: 'GET', + newApiPath: '/api/security/oidc/callback', + }, + }, + }), }, }, createLicensedRouteHandler(async (context, request, response) => { @@ -133,9 +171,9 @@ export function defineOIDCRoutes({ authenticationResponseURI: request.query.authenticationResponseURI, }; } else if (request.query.code || request.query.error) { - if (path === '/api/security/v1/oidc') { + if (isDeprecated) { logger.warn( - `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/callback" URL instead.`, + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version. Use "${serverBasePath}/api/security/oidc/callback" URL instead.`, { tags: ['deprecation'] } ); } @@ -150,7 +188,7 @@ export function defineOIDCRoutes({ }; } else if (request.query.iss) { logger.warn( - `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`, + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version. Use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`, { tags: ['deprecation'] } ); // An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication. @@ -175,6 +213,7 @@ export function defineOIDCRoutes({ // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. for (const path of ['/api/security/oidc/initiate_login', '/api/security/v1/oidc']) { + const isDeprecated = path === '/api/security/v1/oidc'; /** * An HTTP POST request with the payload parameter named `iss` as part of a 3rd party initiated authentication. * See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin @@ -206,13 +245,29 @@ export function defineOIDCRoutes({ authRequired: false, xsrfRequired: false, tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW], + ...(isDeprecated && { + deprecated: { + documentationUrl: docLinks.links.security.deprecatedV1Endpoints, + severity: 'warning', + message: i18n.translate('xpack.security.deprecations.oidcInitiateRouteMessage', { + defaultMessage: + 'The "{path}" URL is deprecated and will be removed in the next major version. Use "/api/security/oidc/initiate_login" instead.', + values: { path }, + }), + reason: { + type: 'migrate', + newApiMethod: 'POST', + newApiPath: '/api/security/oidc/initiate_login', + }, + }, + }), }, }, createLicensedRouteHandler(async (context, request, response) => { const serverBasePath = basePath.serverBasePath; - if (path === '/api/security/v1/oidc') { + if (isDeprecated) { logger.warn( - `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`, + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version. Use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`, { tags: ['deprecation'] } ); } diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 8cee1df2da88b..c45f1eed3affd 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; import type { RouteDefinitionParams } from '..'; import { SAMLAuthenticationProvider, SAMLLogin } from '../../authentication'; @@ -20,6 +21,7 @@ export function defineSAMLRoutes({ basePath, logger, buildFlavor, + docLinks, }: RouteDefinitionParams) { // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. // For a serverless build, do not register deprecated versioned routes @@ -27,6 +29,7 @@ export function defineSAMLRoutes({ '/api/security/saml/callback', ...(buildFlavor !== 'serverless' ? ['/api/security/v1/saml'] : []), ]) { + const isDeprecated = path === '/api/security/v1/saml'; router.post( { path, @@ -48,14 +51,30 @@ export function defineSAMLRoutes({ authRequired: false, xsrfRequired: false, tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW], + ...(isDeprecated && { + deprecated: { + documentationUrl: docLinks.links.security.deprecatedV1Endpoints, + severity: 'warning', + message: i18n.translate('xpack.security.deprecations.samlPostRouteMessage', { + defaultMessage: + 'The "{path}" URL is deprecated and will be removed in the next major version. Use "/api/security/saml/callback" instead.', + values: { path }, + }), + reason: { + type: 'migrate', + newApiMethod: 'POST', + newApiPath: '/api/security/saml/callback', + }, + }, + }), }, }, async (context, request, response) => { - if (path === '/api/security/v1/saml') { + if (isDeprecated) { const serverBasePath = basePath.serverBasePath; logger.warn( // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - `The "${serverBasePath}${path}" URL is deprecated and might stop working in a future release. Please use "${serverBasePath}/api/security/saml/callback" URL instead.` + `The "${serverBasePath}${path}" URL is deprecated and might stop working in a future release. Use "${serverBasePath}/api/security/saml/callback" URL instead.` ); } diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 910578a14789d..e73cd74daf300 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -13,6 +13,7 @@ import { httpServiceMock, loggingSystemMock, } from '@kbn/core/server/mocks'; +import { getDocLinks } from '@kbn/doc-links'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; @@ -50,6 +51,8 @@ export const routeDefinitionParamsMock = { getAnonymousAccessService: jest.fn(), getUserProfileService: jest.fn().mockReturnValue(userProfileServiceMock.createStart()), analyticsService: analyticsServiceMock.createSetup(), + buildFlavor: 'traditional', + docLinks: { links: getDocLinks({ kibanaBranch: 'main', buildFlavor: 'traditional' }) }, } as unknown as DeeplyMockedKeys; }, }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 8b986cc4a3893..cbc1569d963cf 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -8,7 +8,7 @@ import type { Observable } from 'rxjs'; import type { BuildFlavor } from '@kbn/config/src/types'; -import type { HttpResources, IBasePath, Logger } from '@kbn/core/server'; +import type { DocLinksServiceSetup, HttpResources, IBasePath, Logger } from '@kbn/core/server'; import type { KibanaFeature } from '@kbn/features-plugin/server'; import type { SubFeaturePrivilegeIterator } from '@kbn/features-plugin/server/feature_privilege_iterator'; import type { PublicMethodsOf } from '@kbn/utility-types'; @@ -59,6 +59,7 @@ export interface RouteDefinitionParams { getAnonymousAccessService: () => AnonymousAccessServiceStart; analyticsService: AnalyticsServiceSetup; buildFlavor: BuildFlavor; + docLinks: DocLinksServiceSetup; } export function defineRoutes(params: RouteDefinitionParams) { diff --git a/x-pack/plugins/security/tsconfig.json b/x-pack/plugins/security/tsconfig.json index 2a0eabcd914d4..4837d3729e3f9 100644 --- a/x-pack/plugins/security/tsconfig.json +++ b/x-pack/plugins/security/tsconfig.json @@ -87,6 +87,7 @@ "@kbn/security-ui-components", "@kbn/core-http-router-server-mocks", "@kbn/security-authorization-core-common", + "@kbn/doc-links", ], "exclude": [ "target/**/*", From e05d83486b7f2229940d2c4d90549bf84cd23585 Mon Sep 17 00:00:00 2001 From: Rickyanto Ang Date: Tue, 19 Nov 2024 01:20:37 -0800 Subject: [PATCH 03/61] [Cloud Security] Fixed an issue with Host.name Alerts contextual flyout (#200626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR addresses issue with Alerts flyout on host.name. Users are unable to to open Flyout via clicking Expand flyout (Missing this) and also unable to close Alerts datagrid ## Issue Screenshot 2024-11-18 at 9 43 24 AM ## Fix Screenshot 2024-11-18 at 9 44 05 AM --- .../components/alerts/alerts_preview.tsx | 2 +- .../public/flyout/entity_details/host_right/index.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx index c832f12c93f78..a5f08527cdc77 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx @@ -225,7 +225,7 @@ export const AlertsPreview = ({ From 4f96b8591462023ceb76d13ce8c1f4da2659b83a Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Nov 2024 09:35:39 +0000 Subject: [PATCH 04/61] [Ownership] Assign test fixtures, etc to ops team (#200552) ## Summary Assign test fixtures, etc to ops team Contributes to: #192979 Assigned `x-pack/test/plugin_functional/screenshots` per https://github.com/elastic/kibana/pull/94370 --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c5603dd514c33..030f888c3e9ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1454,6 +1454,7 @@ x-pack/test_serverless/**/test_suites/observability/ai_assistant @elastic/obs-ai /test/package @elastic/kibana-operations /test/package/roles @elastic/kibana-operations /test/common/fixtures/plugins/coverage/kibana.json @elastic/kibana-operations +/x-pack/test/plugin_functional/screenshots @elastic/kibana-operations # Assigned per https://github.com/elastic/kibana/pull/94370/files /src/dev/license_checker/config.ts @elastic/kibana-operations /src/dev/ @elastic/kibana-operations /src/setup_node_env/ @elastic/kibana-operations From efc0568e014105637332533e37491f074ec8fe2b Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Tue, 19 Nov 2024 19:07:42 +0900 Subject: [PATCH 05/61] [Security Solution] add defend insights elastic assistant tool (#198676) ### Summary Adds the new Defend Insights Elastic Assistant tool. This assistant tool provides Elastic Defend configuration insights. For this initial PR, only incompatible antivirus detection is supported. Telemetry is collected for success and error events. For incompatible antivirus detection, Defend Insights will review the last 200 file events for the given endpoint and output suspected antiviruses. Improvements such as customizable event count and date range will come in the future. This PR does not include any UI, that will come in a separate PR. 3 internal APIs for interacting with Defend Insights are provided here: - `POST /defend_insights` for creating a new Defend Insight - `GET /defend_insights/{id}` for getting a Defend Insight - `GET /defend_insights` for getting multiple Defend Insights - available optional query params: - `size` - default 10 - `ids` - `connector_id` - `type` - `incompatible_antivirus` - `status` - `running`, `completed`, `failed`, `canceled` - `endpoint_ids` This initial implementation does not include the LangGraph/output chunking upgrades seen in Attack Discovery due to time constraints. We'll look to make this upgrade in a future PR. ### 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#_add_your_labels) --- .github/CODEOWNERS | 5 + .../kbn-elastic-assistant-common/constants.ts | 5 + .../impl/capabilities/index.ts | 1 + .../get_capabilities_route.gen.ts | 1 + .../get_capabilities_route.schema.yaml | 3 + .../defend_insights/common_attributes.gen.ts | 217 +++++++++ .../common_attributes.schema.yaml | 224 ++++++++++ .../get_defend_insight_route.gen.ts | 34 ++ .../get_defend_insight_route.schema.yaml | 45 ++ .../get_defend_insights_route.gen.ts | 59 +++ .../get_defend_insights_route.schema.yaml | 82 ++++ .../impl/schemas/defend_insights/index.ts | 11 + .../post_defend_insights_route.gen.ts | 42 ++ .../post_defend_insights_route.schema.yaml | 77 ++++ .../impl/schemas/index.ts | 3 + .../common/anonymization/index.ts | 1 + .../__mocks__/defend_insights_schema.mock.ts | 109 +++++ .../server/__mocks__/request.ts | 27 ++ .../server/__mocks__/request_context.ts | 6 + .../field_maps_configuration.ts | 174 ++++++++ .../get_defend_insight.test.ts | 69 +++ .../defend_insights/get_defend_insight.ts | 78 ++++ .../defend_insights/helpers.test.ts | 64 +++ .../defend_insights/helpers.ts | 221 ++++++++++ .../defend_insights/index.test.ts | 410 ++++++++++++++++++ .../defend_insights/index.ts | 286 ++++++++++++ .../defend_insights/types.ts | 88 ++++ .../server/ai_assistant_service/index.test.ts | 13 +- .../server/ai_assistant_service/index.ts | 40 +- .../server/lib/langchain/helpers.ts | 4 +- .../lib/telemetry/event_based_telemetry.ts | 96 ++++ .../get_defend_insight.test.ts | 149 +++++++ .../defend_insights/get_defend_insight.ts | 96 ++++ .../get_defend_insights.test.ts | 149 +++++++ .../defend_insights/get_defend_insights.ts | 98 +++++ .../routes/defend_insights/helpers.test.ts | 255 +++++++++++ .../server/routes/defend_insights/helpers.ts | 387 +++++++++++++++++ .../server/routes/defend_insights/index.ts | 10 + .../post_defend_insights.test.ts | 184 ++++++++ .../defend_insights/post_defend_insights.ts | 196 +++++++++ .../server/routes/helpers.ts | 5 +- .../elastic_assistant/server/routes/index.ts | 5 + .../server/routes/register_routes.ts | 10 + .../server/routes/request_context_factory.ts | 10 + .../server/services/app_context.test.ts | 7 + .../plugins/elastic_assistant/server/types.ts | 9 +- .../common/endpoint/constants.ts | 1 + .../common/experimental_features.ts | 5 + .../assistant/tools/defend_insights/errors.ts | 14 + .../get_events/get_file_events_query.ts | 49 +++ .../defend_insights/get_events/index.test.ts | 84 ++++ .../tools/defend_insights/get_events/index.ts | 93 ++++ .../tools/defend_insights/index.test.ts | 69 +++ .../assistant/tools/defend_insights/index.ts | 114 +++++ .../output_parsers/incompatible_antivirus.ts | 28 ++ .../defend_insights/output_parsers/index.ts | 19 + .../prompts/incompatible_antivirus.ts | 16 + .../tools/defend_insights/prompts/index.ts | 25 ++ .../server/assistant/tools/index.ts | 2 + 59 files changed, 4574 insertions(+), 10 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.gen.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.schema.yaml create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.gen.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.schema.yaml create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.gen.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.schema.yaml create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.gen.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.schema.yaml create mode 100644 x-pack/plugins/elastic_assistant/server/__mocks__/defend_insights_schema.mock.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/field_maps_configuration.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/types.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/errors.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/get_file_events_query.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/incompatible_antivirus.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/index.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/incompatible_antivirus.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 030f888c3e9ba..98f3aef6118ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2158,6 +2158,7 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/ /x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/ @elastic/security-defend-workflows /x-pack/plugins/security_solution/common/endpoint/ @elastic/security-defend-workflows /x-pack/plugins/security_solution/common/api/endpoint/ @elastic/security-defend-workflows +x-pack/plugins/security_solution/server/assistant/tools/defend_insights @elastic/security-defend-workflows /x-pack/plugins/security_solution/server/endpoint/ @elastic/security-defend-workflows /x-pack/plugins/security_solution/server/lists_integration/endpoint/ @elastic/security-defend-workflows /x-pack/plugins/security_solution/server/lib/license/ @elastic/security-defend-workflows @@ -2169,6 +2170,10 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/ /x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management @elastic/security-defend-workflows /x-pack/plugins/security_solution_serverless/public/upselling/pages/endpoint_management @elastic/security-defend-workflows /x-pack/plugins/security_solution_serverless/server/endpoint @elastic/security-defend-workflows +x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights @elastic/security-defend-workflows +x-pack/plugins/elastic_assistant/server/__mocks__/defend_insights_schema.mock.ts @elastic/security-defend-workflows +x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights @elastic/security-defend-workflows +x-pack/plugins/elastic_assistant/server/routes/defend_insights @elastic/security-defend-workflows ## Security Solution sub teams - security-telemetry (Data Engineering) x-pack/plugins/security_solution/server/usage/ @elastic/security-data-analytics diff --git a/x-pack/packages/kbn-elastic-assistant-common/constants.ts b/x-pack/packages/kbn-elastic-assistant-common/constants.ts index 49db6c295a51a..7a884936d04e2 100755 --- a/x-pack/packages/kbn-elastic-assistant-common/constants.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/constants.ts @@ -55,3 +55,8 @@ export const ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL = export const ELASTIC_AI_ASSISTANT_EVALUATE_URL = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/evaluate` as const; + +// Defend insights +export const DEFEND_INSIGHTS_TOOL_ID = 'defend-insights'; +export const DEFEND_INSIGHTS = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/defend_insights`; +export const DEFEND_INSIGHTS_BY_ID = `${DEFEND_INSIGHTS}/{id}`; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts index d883dfe98d564..0e204b4b949ea 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts @@ -20,4 +20,5 @@ export type AssistantFeatureKey = keyof AssistantFeatures; */ export const defaultAssistantFeatures = Object.freeze({ assistantModelEvaluation: false, + defendInsights: false, }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts index 0f8b6235d7dc9..8777e8d728279 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts @@ -19,4 +19,5 @@ import { z } from '@kbn/zod'; export type GetCapabilitiesResponse = z.infer; export const GetCapabilitiesResponse = z.object({ assistantModelEvaluation: z.boolean(), + defendInsights: z.boolean(), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml index a042abd391796..e9b6ca9697256 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml @@ -22,8 +22,11 @@ paths: properties: assistantModelEvaluation: type: boolean + defendInsights: + type: boolean required: - assistantModelEvaluation + - defendInsights '400': description: Generic Error content: diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.gen.ts new file mode 100644 index 0000000000000..e070c3129e192 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.gen.ts @@ -0,0 +1,217 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Common Defend Insights Attributes + * version: not applicable + */ + +import { z } from '@kbn/zod'; + +import { NonEmptyString, User } from '../common_attributes.gen'; +import { Replacements, ApiConfig } from '../conversations/common_attributes.gen'; + +/** + * A Defend insight event + */ +export type DefendInsightEvent = z.infer; +export const DefendInsightEvent = z.object({ + /** + * The event's ID + */ + id: z.string(), + /** + * The endpoint's ID + */ + endpointId: z.string(), + /** + * The value of the event + */ + value: z.string(), +}); + +/** + * The insight type (ie. incompatible_antivirus) + */ +export type DefendInsightType = z.infer; +export const DefendInsightType = z.enum(['incompatible_antivirus', 'noisy_process_tree']); +export type DefendInsightTypeEnum = typeof DefendInsightType.enum; +export const DefendInsightTypeEnum = DefendInsightType.enum; + +/** + * A Defend insight generated from endpoint events + */ +export type DefendInsight = z.infer; +export const DefendInsight = z.object({ + /** + * The group category of the events (ie. Windows Defender) + */ + group: z.string(), + /** + * An array of event objects + */ + events: z.array(DefendInsightEvent).optional(), +}); + +/** + * Array of Defend insights + */ +export type DefendInsights = z.infer; +export const DefendInsights = z.array(DefendInsight); + +/** + * The status of the Defend insight. + */ +export type DefendInsightStatus = z.infer; +export const DefendInsightStatus = z.enum(['running', 'succeeded', 'failed', 'canceled']); +export type DefendInsightStatusEnum = typeof DefendInsightStatus.enum; +export const DefendInsightStatusEnum = DefendInsightStatus.enum; + +/** + * Run durations for the Defend insight + */ +export type DefendInsightGenerationInterval = z.infer; +export const DefendInsightGenerationInterval = z.object({ + /** + * The time the Defend insight was generated + */ + date: z.string(), + /** + * The duration of the Defend insight generation + */ + durationMs: z.number().int(), +}); + +export type DefendInsightsResponse = z.infer; +export const DefendInsightsResponse = z.object({ + id: NonEmptyString, + timestamp: NonEmptyString.optional(), + /** + * The last time the Defend insight was updated. + */ + updatedAt: z.string(), + /** + * The last time the Defend insight was viewed in the browser. + */ + lastViewedAt: z.string(), + /** + * The number of events in the context. + */ + eventsContextCount: z.number().int().optional(), + /** + * The time the Defend insight was created. + */ + createdAt: z.string(), + replacements: Replacements.optional(), + users: z.array(User), + /** + * The status of the Defend insight. + */ + status: DefendInsightStatus, + endpointIds: z.array(NonEmptyString), + insightType: DefendInsightType, + /** + * The Defend insights. + */ + insights: DefendInsights, + /** + * LLM API configuration. + */ + apiConfig: ApiConfig, + /** + * Kibana space + */ + namespace: z.string(), + /** + * The backing index required for update requests. + */ + backingIndex: z.string(), + /** + * The most 5 recent generation intervals + */ + generationIntervals: z.array(DefendInsightGenerationInterval), + /** + * The average generation interval in milliseconds + */ + averageIntervalMs: z.number().int(), + /** + * The reason for a status of failed. + */ + failureReason: z.string().optional(), +}); + +export type DefendInsightUpdateProps = z.infer; +export const DefendInsightUpdateProps = z.object({ + id: NonEmptyString, + /** + * LLM API configuration. + */ + apiConfig: ApiConfig.optional(), + /** + * The number of events in the context. + */ + eventsContextCount: z.number().int().optional(), + /** + * The Defend insights. + */ + insights: DefendInsights.optional(), + /** + * The status of the Defend insight. + */ + status: DefendInsightStatus.optional(), + replacements: Replacements.optional(), + /** + * The most 5 recent generation intervals + */ + generationIntervals: z.array(DefendInsightGenerationInterval).optional(), + /** + * The backing index required for update requests. + */ + backingIndex: z.string(), + /** + * The reason for a status of failed. + */ + failureReason: z.string().optional(), + /** + * The last time the Defend insight was viewed in the browser. + */ + lastViewedAt: z.string().optional(), +}); + +export type DefendInsightsUpdateProps = z.infer; +export const DefendInsightsUpdateProps = z.array(DefendInsightUpdateProps); + +export type DefendInsightCreateProps = z.infer; +export const DefendInsightCreateProps = z.object({ + /** + * The Defend insight id. + */ + id: z.string().optional(), + /** + * The status of the Defend insight. + */ + status: DefendInsightStatus, + /** + * The number of events in the context. + */ + eventsContextCount: z.number().int().optional(), + endpointIds: z.array(NonEmptyString), + insightType: DefendInsightType, + /** + * The Defend insights. + */ + insights: DefendInsights, + /** + * LLM API configuration. + */ + apiConfig: ApiConfig, + replacements: Replacements.optional(), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.schema.yaml new file mode 100644 index 0000000000000..5c27449c7d346 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/common_attributes.schema.yaml @@ -0,0 +1,224 @@ +openapi: 3.0.0 +info: + title: Common Defend Insights Attributes + version: 'not applicable' +paths: {} +components: + x-codegen-enabled: true + schemas: + DefendInsightEvent: + type: object + description: A Defend insight event + required: + - 'id' + - 'endpointId' + - 'value' + properties: + id: + description: The event's ID + type: string + endpointId: + description: The endpoint's ID + type: string + value: + description: The value of the event + type: string + + DefendInsightType: + description: The insight type (ie. incompatible_antivirus) + type: string + enum: + - incompatible_antivirus + - noisy_process_tree + + DefendInsight: + type: object + description: A Defend insight generated from endpoint events + required: + - 'group' + properties: + group: + description: The group category of the events (ie. Windows Defender) + type: string + events: + description: An array of event objects + type: array + items: + $ref: '#/components/schemas/DefendInsightEvent' + + DefendInsights: + type: array + description: Array of Defend insights + items: + $ref: '#/components/schemas/DefendInsight' + + DefendInsightsResponse: + type: object + required: + - apiConfig + - id + - createdAt + - updatedAt + - lastViewedAt + - users + - namespace + - endpointIds + - insightType + - insights + - status + - backingIndex + - generationIntervals + - averageIntervalMs + properties: + id: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + 'timestamp': + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + updatedAt: + description: The last time the Defend insight was updated. + type: string + lastViewedAt: + description: The last time the Defend insight was viewed in the browser. + type: string + eventsContextCount: + type: integer + description: The number of events in the context. + createdAt: + description: The time the Defend insight was created. + type: string + replacements: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' + users: + type: array + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/User' + status: + $ref: '#/components/schemas/DefendInsightStatus' + description: The status of the Defend insight. + endpointIds: + type: array + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + insightType: + $ref: '#/components/schemas/DefendInsightType' + insights: + $ref: '#/components/schemas/DefendInsights' + description: The Defend insights. + apiConfig: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig' + description: LLM API configuration. + namespace: + type: string + description: Kibana space + backingIndex: + type: string + description: The backing index required for update requests. + generationIntervals: + type: array + description: The most 5 recent generation intervals + items: + $ref: '#/components/schemas/DefendInsightGenerationInterval' + averageIntervalMs: + type: integer + description: The average generation interval in milliseconds + failureReason: + type: string + description: The reason for a status of failed. + + DefendInsightGenerationInterval: + type: object + description: Run durations for the Defend insight + required: + - 'date' + - 'durationMs' + properties: + date: + description: The time the Defend insight was generated + type: string + durationMs: + description: The duration of the Defend insight generation + type: integer + + DefendInsightStatus: + type: string + description: The status of the Defend insight. + enum: + - running + - succeeded + - failed + - canceled + + DefendInsightUpdateProps: + type: object + required: + - id + - backingIndex + properties: + id: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + apiConfig: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig' + description: LLM API configuration. + eventsContextCount: + type: integer + description: The number of events in the context. + insights: + $ref: '#/components/schemas/DefendInsights' + description: The Defend insights. + status: + $ref: '#/components/schemas/DefendInsightStatus' + description: The status of the Defend insight. + replacements: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' + generationIntervals: + type: array + description: The most 5 recent generation intervals + items: + $ref: '#/components/schemas/DefendInsightGenerationInterval' + backingIndex: + type: string + description: The backing index required for update requests. + failureReason: + type: string + description: The reason for a status of failed. + lastViewedAt: + description: The last time the Defend insight was viewed in the browser. + type: string + + DefendInsightsUpdateProps: + type: array + items: + $ref: '#/components/schemas/DefendInsightUpdateProps' + + DefendInsightCreateProps: + type: object + required: + - endpointIds + - insightType + - insights + - apiConfig + - status + properties: + id: + type: string + description: The Defend insight id. + status: + $ref: '#/components/schemas/DefendInsightStatus' + description: The status of the Defend insight. + eventsContextCount: + type: integer + description: The number of events in the context. + endpointIds: + type: array + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + insightType: + $ref: '#/components/schemas/DefendInsightType' + insights: + $ref: '#/components/schemas/DefendInsights' + description: The Defend insights. + apiConfig: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig' + description: LLM API configuration. + replacements: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.gen.ts new file mode 100644 index 0000000000000..fafaca8f48ead --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.gen.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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get Defend Insight API endpoint + * version: 1 + */ + +import { z } from '@kbn/zod'; + +import { NonEmptyString } from '../common_attributes.gen'; +import { DefendInsightsResponse } from './common_attributes.gen'; + +export type DefendInsightGetRequestParams = z.infer; +export const DefendInsightGetRequestParams = z.object({ + /** + * The Defend insight id + */ + id: NonEmptyString, +}); +export type DefendInsightGetRequestParamsInput = z.input; + +export type DefendInsightGetResponse = z.infer; +export const DefendInsightGetResponse = z.object({ + data: DefendInsightsResponse.nullable().optional(), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.schema.yaml new file mode 100644 index 0000000000000..2684bf53cf87b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insight_route.schema.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: + title: Get Defend Insight API endpoint + version: '1' +paths: + /internal/elastic_assistant/defend_insights/{id}: + get: + x-codegen-enabled: true + x-labels: [ess, serverless] + operationId: DefendInsightGet + description: Get Defend insight by id + summary: Get Defend insight data + tags: + - defend_insights + parameters: + - name: 'id' + in: path + required: true + description: The Defend insight id + schema: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: './common_attributes.schema.yaml#/components/schemas/DefendInsightsResponse' + nullable: true + '400': + description: Generic Error + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.gen.ts new file mode 100644 index 0000000000000..0a2f3d618a869 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.gen.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get Defend Insights API endpoint + * version: 1 + */ + +import { z } from '@kbn/zod'; +import { ArrayFromString } from '@kbn/zod-helpers'; + +import { NonEmptyString } from '../common_attributes.gen'; +import { + DefendInsightType, + DefendInsightStatus, + DefendInsightsResponse, +} from './common_attributes.gen'; + +export type DefendInsightsGetRequestQuery = z.infer; +export const DefendInsightsGetRequestQuery = z.object({ + /** + * The insight ids for which to get Defend insights + */ + ids: ArrayFromString(NonEmptyString).optional(), + /** + * The connector id for which to get Defend insights + */ + connector_id: NonEmptyString.optional(), + /** + * The insight type for which to get Defend insights + */ + type: DefendInsightType.optional(), + /** + * The status for which to get Defend insights + */ + status: DefendInsightStatus.optional(), + /** + * The endpoint ids for which to get Defend insights + */ + endpoint_ids: ArrayFromString(NonEmptyString).optional(), + /** + * The number of Defend insights to return + */ + size: z.coerce.number().optional(), +}); +export type DefendInsightsGetRequestQueryInput = z.input; + +export type DefendInsightsGetResponse = z.infer; +export const DefendInsightsGetResponse = z.object({ + data: z.array(DefendInsightsResponse), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.schema.yaml new file mode 100644 index 0000000000000..5d7e0b5358f81 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/get_defend_insights_route.schema.yaml @@ -0,0 +1,82 @@ +openapi: 3.0.0 +info: + title: Get Defend Insights API endpoint + version: '1' +paths: + /internal/elastic_assistant/defend_insights: + get: + x-codegen-enabled: true + x-labels: [ess, serverless] + operationId: DefendInsightsGet + description: Get relevant data for Defend insights + summary: Get relevant data for Defend insights + tags: + - defend_insights + parameters: + - name: 'ids' + in: query + required: false + description: The insight ids for which to get Defend insights + schema: + type: array + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + - name: 'connector_id' + in: query + required: false + description: The connector id for which to get Defend insights + schema: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + - name: 'type' + in: query + required: false + description: The insight type for which to get Defend insights + schema: + $ref: './common_attributes.schema.yaml#/components/schemas/DefendInsightType' + - name: 'status' + in: query + required: false + description: The status for which to get Defend insights + schema: + $ref: './common_attributes.schema.yaml#/components/schemas/DefendInsightStatus' + - name: 'endpoint_ids' + in: query + required: false + description: The endpoint ids for which to get Defend insights + schema: + type: array + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + - name: 'size' + in: query + required: false + description: The number of Defend insights to return + schema: + type: number + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: './common_attributes.schema.yaml#/components/schemas/DefendInsightsResponse' + '400': + description: Generic Error + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/index.ts new file mode 100644 index 0000000000000..0518abdf6dcb7 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/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 * from './common_attributes.gen'; +export * from './get_defend_insight_route.gen'; +export * from './get_defend_insights_route.gen'; +export * from './post_defend_insights_route.gen'; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.gen.ts new file mode 100644 index 0000000000000..cc0ccfeea1980 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.gen.ts @@ -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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Post Defend Insights API endpoint + * version: 1 + */ + +import { z } from '@kbn/zod'; + +import { NonEmptyString } from '../common_attributes.gen'; +import { DefendInsightType, DefendInsightsResponse } from './common_attributes.gen'; +import { AnonymizationFieldResponse } from '../anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { ApiConfig, Replacements } from '../conversations/common_attributes.gen'; + +export type DefendInsightsPostRequestBody = z.infer; +export const DefendInsightsPostRequestBody = z.object({ + endpointIds: z.array(NonEmptyString), + insightType: DefendInsightType, + anonymizationFields: z.array(AnonymizationFieldResponse), + /** + * LLM API configuration. + */ + apiConfig: ApiConfig, + langSmithProject: z.string().optional(), + langSmithApiKey: z.string().optional(), + model: z.string().optional(), + replacements: Replacements.optional(), + subAction: z.enum(['invokeAI', 'invokeStream']), +}); +export type DefendInsightsPostRequestBodyInput = z.input; + +export type DefendInsightsPostResponse = z.infer; +export const DefendInsightsPostResponse = DefendInsightsResponse; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.schema.yaml new file mode 100644 index 0000000000000..87c7cdbb81a8e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/defend_insights/post_defend_insights_route.schema.yaml @@ -0,0 +1,77 @@ +openapi: 3.0.0 +info: + title: Post Defend Insights API endpoint + version: '1' +components: + x-codegen-enabled: true + +paths: + /internal/elastic_assistant/defend_insights: + post: + x-codegen-enabled: true + x-labels: [ess, serverless] + operationId: DefendInsightsPost + description: Generate Elastic Defend configuration insights + summary: Generate Elastic Defend configuration insights from endpoint events via the Elastic Assistant + tags: + - defend_insights + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - endpointIds + - insightType + - apiConfig + - anonymizationFields + - subAction + properties: + endpointIds: + type: array + items: + $ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString' + insightType: + $ref: './common_attributes.schema.yaml#/components/schemas/DefendInsightType' + anonymizationFields: + type: array + items: + $ref: '../anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml#/components/schemas/AnonymizationFieldResponse' + apiConfig: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig' + description: LLM API configuration. + langSmithProject: + type: string + langSmithApiKey: + type: string + model: + type: string + replacements: + $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' + subAction: + type: string + enum: + - invokeAI + - invokeStream + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: './common_attributes.schema.yaml#/components/schemas/DefendInsightsResponse' + '400': + description: Bad request + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string + diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts index 9233791a870c3..02ac9b7b1ba90 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts @@ -27,6 +27,9 @@ export * from './attack_discovery/get_attack_discovery_route.gen'; export * from './attack_discovery/post_attack_discovery_route.gen'; export * from './attack_discovery/cancel_attack_discovery_route.gen'; +// Defend insight Schemas +export * from './defend_insights'; + // Chat Schemas export * from './chat/post_chat_complete_route.gen'; diff --git a/x-pack/plugins/elastic_assistant/common/anonymization/index.ts b/x-pack/plugins/elastic_assistant/common/anonymization/index.ts index ebef2dff8bdef..9b8007a9129bf 100644 --- a/x-pack/plugins/elastic_assistant/common/anonymization/index.ts +++ b/x-pack/plugins/elastic_assistant/common/anonymization/index.ts @@ -9,6 +9,7 @@ export const DEFAULT_ALLOW = [ '_id', '@timestamp', + 'agent.id', 'cloud.availability_zone', 'cloud.provider', 'cloud.region', diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/defend_insights_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/defend_insights_schema.mock.ts new file mode 100644 index 0000000000000..d25b8bb09b13d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/defend_insights_schema.mock.ts @@ -0,0 +1,109 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { DefendInsightStatus, DefendInsightType } from '@kbn/elastic-assistant-common'; + +import type { EsDefendInsightSchema } from '../ai_assistant_data_clients/defend_insights/types'; + +export const getDefendInsightsSearchEsMock = () => { + const searchResponse: estypes.SearchResponse = { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1, + hits: [ + { + _index: '.kibana-elastic-ai-assistant-defend-insights-default', + _id: '655c52ec-49ee-4d20-87e5-7edd6d8f84e8', + _score: 1, + _source: { + '@timestamp': '2024-09-24T10:48:46.847Z', + created_at: '2024-09-24T10:48:46.847Z', + users: [ + { + id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + name: 'elastic', + }, + ], + status: DefendInsightStatus.Enum.succeeded, + api_config: { + action_type_id: '.bedrock', + connector_id: 'ac4e19d1-e2e2-49af-bf4b-59428473101c', + model: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }, + endpoint_ids: ['6e09ec1c-644c-4148-a02d-be451c35400d'], + insight_type: DefendInsightType.Enum.incompatible_antivirus, + insights: [ + { + group: 'windows_defenders', + events: [], + }, + ], + updated_at: '2024-09-24T10:48:59.952Z', + last_viewed_at: '2024-09-24T10:49:53.522Z', + namespace: 'default', + id: '655c52ec-49ee-4d20-87e5-7edd6d8f84e8', + generation_intervals: [ + { + date: '2024-09-24T10:48:59.952Z', + duration_ms: 13113, + }, + ], + average_interval_ms: 13113, + replacements: [ + { + uuid: '2009c67b-89b8-43d9-b502-2c32f71875a0', + value: 'root', + }, + { + uuid: '9f7f91b6-6853-48b7-bfb8-403f5efb2364', + value: 'joey-dev-default-3539', + }, + { + uuid: 'c08e4851-7234-408a-8083-7fd5740e4255', + value: 'syslog', + }, + { + uuid: '826c58bd-1466-42fd-af1f-9094c155811b', + value: 'messagebus', + }, + { + uuid: '1f8e3668-c7d7-4fdb-8195-3f337dfe10bf', + value: 'polkitd', + }, + { + uuid: 'e101d201-c675-47f3-b488-77bd0ce71920', + value: 'systemd-network', + }, + { + uuid: '0144102f-d69c-43a3-bf3b-04bde7d1b4e8', + value: 'systemd-resolve', + }, + { + uuid: '00c5a919-949e-4031-956e-3eeb071e9210', + value: 'systemd-timesync', + }, + ], + events_context_count: 100, + }, + }, + ], + }, + }; + return searchResponse; +}; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts index b62cd24e938eb..26db891242884 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts @@ -11,10 +11,16 @@ import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID, CAPABILITIES, } from '../../common/constants'; +import type { + DefendInsightsGetRequestQuery, + DefendInsightsPostRequestBody, +} from '@kbn/elastic-assistant-common'; import { AttackDiscoveryPostRequestBody, ConversationCreateProps, ConversationUpdateProps, + DEFEND_INSIGHTS, + DEFEND_INSIGHTS_BY_ID, ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_BULK_ACTION, ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND, ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, @@ -235,3 +241,24 @@ export const postAttackDiscoveryRequest = (body: AttackDiscoveryPostRequestBody) path: ATTACK_DISCOVERY, body, }); + +export const getDefendInsightRequest = (insightId: string) => + requestMock.create({ + method: 'get', + path: DEFEND_INSIGHTS_BY_ID, + params: { id: insightId }, + }); + +export const getDefendInsightsRequest = (queryParams: DefendInsightsGetRequestQuery) => + requestMock.create({ + method: 'get', + path: DEFEND_INSIGHTS, + query: queryParams, + }); + +export const postDefendInsightsRequest = (body: DefendInsightsPostRequestBody) => + requestMock.create({ + method: 'post', + path: DEFEND_INSIGHTS, + body, + }); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index 19d98633a83c9..77bd6b00105b6 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -28,6 +28,7 @@ import { } from '../ai_assistant_data_clients/knowledge_base'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; +import { DefendInsightsDataClient } from '../ai_assistant_data_clients/defend_insights'; import { authenticatedUser } from './user'; export const createMockClients = () => { @@ -47,6 +48,7 @@ export const createMockClients = () => { getAIAssistantKnowledgeBaseDataClient: knowledgeBaseDataClientMock.create(), getAIAssistantPromptsDataClient: dataClientMock.create(), getAttackDiscoveryDataClient: attackDiscoveryDataClientMock.create(), + getDefendInsightsDataClient: dataClientMock.create(), getAIAssistantAnonymizationFieldsDataClient: dataClientMock.create(), getSpaceId: jest.fn(), getCurrentUser: jest.fn(), @@ -125,6 +127,10 @@ const createElasticAssistantRequestContextMock = ( () => clients.elasticAssistant.getAttackDiscoveryDataClient ) as unknown as jest.MockInstance, [], unknown> & (() => Promise), + getDefendInsightsDataClient: jest.fn( + () => clients.elasticAssistant.getDefendInsightsDataClient + ) as unknown as jest.MockInstance, [], unknown> & + (() => Promise), getAIAssistantKnowledgeBaseDataClient: jest.fn( () => clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient ) as unknown as jest.MockInstance< diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/field_maps_configuration.ts new file mode 100644 index 0000000000000..5769ab4557102 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/field_maps_configuration.ts @@ -0,0 +1,174 @@ +/* + * 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 { FieldMap } from '@kbn/data-stream-adapter'; + +export const defendInsightsFieldMap: FieldMap = { + '@timestamp': { + type: 'date', + array: false, + required: false, + }, + users: { + type: 'nested', + array: true, + required: false, + }, + 'users.id': { + type: 'keyword', + array: false, + required: true, + }, + 'users.name': { + type: 'keyword', + array: false, + required: false, + }, + id: { + type: 'keyword', + array: false, + required: true, + }, + last_viewed_at: { + type: 'date', + array: false, + required: true, + }, + updated_at: { + type: 'date', + array: false, + required: true, + }, + created_at: { + type: 'date', + array: false, + required: true, + }, + endpoint_ids: { + type: 'keyword', + array: true, + required: false, + }, + insight_type: { + type: 'keyword', + required: true, + }, + insights: { + type: 'nested', + array: true, + required: false, + }, + 'insights.group': { + type: 'keyword', + array: true, + required: true, + }, + 'insights.events': { + type: 'nested', + array: true, + required: false, + }, + 'insights.events.endpoint_id': { + type: 'keyword', + array: false, + required: true, + }, + 'insights.events.id': { + type: 'keyword', + array: false, + required: true, + }, + 'insights.events.value': { + type: 'text', + array: false, + required: true, + }, + replacements: { + type: 'object', + array: false, + required: false, + }, + 'replacements.value': { + type: 'keyword', + array: false, + required: false, + }, + 'replacements.uuid': { + type: 'keyword', + array: false, + required: false, + }, + api_config: { + type: 'object', + array: false, + required: true, + }, + 'api_config.connector_id': { + type: 'keyword', + array: false, + required: true, + }, + 'api_config.action_type_id': { + type: 'keyword', + array: false, + required: false, + }, + 'api_config.default_system_prompt_id': { + type: 'keyword', + array: false, + required: false, + }, + 'api_config.provider': { + type: 'keyword', + array: false, + required: false, + }, + 'api_config.model': { + type: 'keyword', + array: false, + required: false, + }, + events_context_count: { + type: 'integer', + array: false, + required: false, + }, + status: { + type: 'keyword', + array: false, + required: true, + }, + namespace: { + type: 'keyword', + array: false, + required: true, + }, + average_interval_ms: { + type: 'integer', + array: false, + required: false, + }, + failure_reason: { + type: 'keyword', + array: false, + required: false, + }, + generation_intervals: { + type: 'nested', + array: true, + required: false, + }, + 'generation_intervals.date': { + type: 'date', + array: false, + required: true, + }, + 'generation_intervals.duration_ms': { + type: 'integer', + array: false, + required: true, + }, +} as const; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.test.ts new file mode 100644 index 0000000000000..415487534a1b6 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { AuthenticatedUser } from '@kbn/core-security-common'; + +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { loggerMock } from '@kbn/logging-mocks'; + +import { getDefendInsightsSearchEsMock } from '../../__mocks__/defend_insights_schema.mock'; +import { getDefendInsight } from './get_defend_insight'; + +const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); +const mockLogger = loggerMock.create(); + +const mockResponse = getDefendInsightsSearchEsMock(); + +const user = { + username: 'test_user', + profile_uid: '1234', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; +const mockRequest = { + esClient: mockEsClient, + index: 'defend-insights-index', + id: 'insight-id', + user, + logger: mockLogger, +}; +describe('getDefendInsight', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get defend insight by id successfully', async () => { + mockEsClient.search.mockResolvedValueOnce(mockResponse); + + const response = await getDefendInsight(mockRequest); + + expect(response).not.toBeNull(); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('should return null if no defend insights found', async () => { + mockEsClient.search.mockResolvedValueOnce({ ...mockResponse, hits: { hits: [] } }); + + const response = await getDefendInsight(mockRequest); + + expect(response).toBeNull(); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('should throw error on elasticsearch search failure', async () => { + mockEsClient.search.mockRejectedValueOnce(new Error('Elasticsearch error')); + + await expect(getDefendInsight(mockRequest)).rejects.toThrowError('Elasticsearch error'); + + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.ts new file mode 100644 index 0000000000000..4eeef2afd8738 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/get_defend_insight.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; +import { DefendInsightsResponse } from '@kbn/elastic-assistant-common'; + +import { EsDefendInsightSchema } from './types'; +import { transformESSearchToDefendInsights } from './helpers'; + +export interface GetDefendInsightParams { + esClient: ElasticsearchClient; + logger: Logger; + index: string; + id: string; + user: AuthenticatedUser; +} + +export const getDefendInsight = async ({ + esClient, + logger, + index, + id, + user, +}: GetDefendInsightParams): Promise => { + const filterByUser = [ + { + nested: { + path: 'users', + query: { + bool: { + must: [ + { + match: user.profile_uid + ? { 'users.id': user.profile_uid } + : { 'users.name': user.username }, + }, + ], + }, + }, + }, + }, + ]; + try { + const response = await esClient.search({ + query: { + bool: { + must: [ + { + bool: { + should: [ + { + term: { + _id: id, + }, + }, + ], + }, + }, + ...filterByUser, + ], + }, + }, + _source: true, + ignore_unavailable: true, + index, + seq_no_primary_term: true, + }); + const insights = transformESSearchToDefendInsights(response); + return insights[0] ?? null; + } catch (err) { + logger.error(`Error fetching Defend insight: ${err} with id: ${id}`); + throw err; + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.test.ts new file mode 100644 index 0000000000000..8e0793218154a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { DefendInsightsGetRequestQuery } from '@kbn/elastic-assistant-common'; + +import { DefendInsightType, DefendInsightStatus } from '@kbn/elastic-assistant-common'; + +import { queryParamsToEsQuery } from './helpers'; + +describe('defend insights data client helpers', () => { + describe('queryParamsToEsQuery', () => { + let queryParams: DefendInsightsGetRequestQuery; + let expectedQuery: object[]; + + function getDefaultQueryParams(): DefendInsightsGetRequestQuery { + return { + ids: ['insight-id1', 'insight-id2'], + endpoint_ids: ['endpoint-id1', 'endpoint-id2'], + connector_id: 'connector-id1', + type: DefendInsightType.Enum.incompatible_antivirus, + status: DefendInsightStatus.Enum.succeeded, + }; + } + + function getDefaultExpectedQuery(): object[] { + return [ + { terms: { _id: queryParams.ids } }, + { terms: { endpoint_ids: queryParams.endpoint_ids } }, + { term: { 'api_config.connector_id': queryParams.connector_id } }, + { term: { insight_type: queryParams.type } }, + { term: { status: queryParams.status } }, + ]; + } + + beforeEach(() => { + queryParams = getDefaultQueryParams(); + expectedQuery = getDefaultExpectedQuery(); + }); + + it('should correctly convert valid query parameters to Elasticsearch query format', () => { + const result = queryParamsToEsQuery(queryParams); + expect(result).toEqual(expectedQuery); + }); + + it('should ignore invalid query parameters', () => { + const badParams = { + ...queryParams, + invalid_param: 'invalid value', + }; + + const result = queryParamsToEsQuery(badParams); + expect(result).toEqual(expectedQuery); + }); + + it('should handle empty query parameters', () => { + const result = queryParamsToEsQuery({}); + expect(result).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.ts new file mode 100644 index 0000000000000..b8164f53d9815 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/helpers.ts @@ -0,0 +1,221 @@ +/* + * 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 { get as _get, isArray } from 'lodash'; + +import type { estypes } from '@elastic/elasticsearch'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { AuthenticatedUser } from '@kbn/core/server'; +import type { + DefendInsightCreateProps, + DefendInsightUpdateProps, + DefendInsightsResponse, + DefendInsightsGetRequestQuery, +} from '@kbn/elastic-assistant-common'; + +import type { + CreateDefendInsightSchema, + EsDefendInsightSchema, + UpdateDefendInsightSchema, +} from './types'; + +export const transformESSearchToDefendInsights = ( + response: estypes.SearchResponse +): DefendInsightsResponse[] => { + return response.hits.hits + .filter((hit) => hit._source !== undefined) + .map((hit) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const insightSchema = hit._source!; + const defendInsight: DefendInsightsResponse = { + timestamp: insightSchema['@timestamp'], + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: hit._id!, + backingIndex: hit._index, + createdAt: insightSchema.created_at, + updatedAt: insightSchema.updated_at, + lastViewedAt: insightSchema.last_viewed_at, + users: + insightSchema.users?.map((user) => ({ + id: user.id, + name: user.name, + })) ?? [], + namespace: insightSchema.namespace, + status: insightSchema.status, + eventsContextCount: insightSchema.events_context_count, + apiConfig: { + connectorId: insightSchema.api_config.connector_id, + actionTypeId: insightSchema.api_config.action_type_id, + defaultSystemPromptId: insightSchema.api_config.default_system_prompt_id, + model: insightSchema.api_config.model, + provider: insightSchema.api_config.provider, + }, + endpointIds: insightSchema.endpoint_ids, + insightType: insightSchema.insight_type, + insights: insightSchema.insights.map((insight) => ({ + group: insight.group, + events: insight.events?.map((event) => ({ + id: event.id, + endpointId: event.endpoint_id, + value: event.value, + })), + })), + replacements: insightSchema.replacements?.reduce((acc: Record, r) => { + acc[r.uuid] = r.value; + return acc; + }, {}), + generationIntervals: + insightSchema.generation_intervals?.map((interval) => ({ + date: interval.date, + durationMs: interval.duration_ms, + })) ?? [], + averageIntervalMs: insightSchema.average_interval_ms ?? 0, + failureReason: insightSchema.failure_reason, + }; + + return defendInsight; + }); +}; + +export const transformToCreateScheme = ( + createdAt: string, + spaceId: string, + user: AuthenticatedUser, + { + endpointIds, + insightType, + insights, + apiConfig, + eventsContextCount, + replacements, + status, + }: DefendInsightCreateProps +): CreateDefendInsightSchema => { + return { + '@timestamp': createdAt, + created_at: createdAt, + users: [ + { + id: user.profile_uid, + name: user.username, + }, + ], + status, + api_config: { + action_type_id: apiConfig.actionTypeId, + connector_id: apiConfig.connectorId, + default_system_prompt_id: apiConfig.defaultSystemPromptId, + model: apiConfig.model, + provider: apiConfig.provider, + }, + events_context_count: eventsContextCount, + endpoint_ids: endpointIds, + insight_type: insightType, + insights: insights?.map((insight) => ({ + group: insight.group, + events: insight.events?.map((event) => ({ + id: event.id, + endpoint_id: event.endpointId, + value: event.value, + })), + })), + updated_at: createdAt, + last_viewed_at: createdAt, + replacements: replacements + ? Object.keys(replacements).map((key) => ({ + uuid: key, + value: replacements[key], + })) + : undefined, + namespace: spaceId, + }; +}; + +export const transformToUpdateScheme = ( + updatedAt: string, + { + eventsContextCount, + apiConfig, + insights, + failureReason, + generationIntervals, + id, + replacements, + lastViewedAt, + status, + }: DefendInsightUpdateProps +): UpdateDefendInsightSchema => { + const averageIntervalMsObj = + generationIntervals && generationIntervals.length > 0 + ? { + average_interval_ms: Math.trunc( + generationIntervals.reduce((acc, interval) => acc + interval.durationMs, 0) / + generationIntervals.length + ), + generation_intervals: generationIntervals.map((interval) => ({ + date: interval.date, + duration_ms: interval.durationMs, + })), + } + : {}; + return { + events_context_count: eventsContextCount, + ...(apiConfig + ? { + api_config: { + action_type_id: apiConfig.actionTypeId, + connector_id: apiConfig.connectorId, + default_system_prompt_id: apiConfig.defaultSystemPromptId, + model: apiConfig.model, + provider: apiConfig.provider, + }, + } + : {}), + ...(insights + ? { + insights: insights.map((insight) => ({ + group: insight.group, + events: insight.events?.map((event) => ({ + id: event.id, + endpoint_id: event.endpointId, + value: event.value, + })), + })), + } + : {}), + failure_reason: failureReason, + id, + replacements: replacements + ? Object.keys(replacements).map((key) => ({ + uuid: key, + value: replacements[key], + })) + : undefined, + ...(status ? { status } : {}), + // only update updated_at time if this is not an update to last_viewed_at + ...(lastViewedAt ? { last_viewed_at: lastViewedAt } : { updated_at: updatedAt }), + ...averageIntervalMsObj, + }; +}; + +const validParams = new Set(['ids', 'endpoint_ids', 'connector_id', 'type', 'status']); +const paramKeyMap = { ids: '_id', connector_id: 'api_config.connector_id', type: 'insight_type' }; +export function queryParamsToEsQuery( + queryParams: DefendInsightsGetRequestQuery +): QueryDslQueryContainer[] { + return Object.entries(queryParams).reduce((acc: object[], [k, v]) => { + if (!validParams.has(k)) { + return acc; + } + + const filterKey = isArray(v) ? 'terms' : 'term'; + const paramKey = _get(paramKeyMap, k, k); + const next = { [filterKey]: { [paramKey]: v } }; + + return [...acc, next]; + }, []); +} diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.test.ts new file mode 100644 index 0000000000000..704ee9b962554 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.test.ts @@ -0,0 +1,410 @@ +/* + * 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 @typescript-eslint/no-explicit-any */ + +import type { AuthenticatedUser } from '@kbn/core-security-common'; +import type { + DefendInsightCreateProps, + DefendInsightsUpdateProps, + DefendInsightsGetRequestQuery, + DefendInsightsResponse, +} from '@kbn/elastic-assistant-common'; + +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DefendInsightStatus, DefendInsightType } from '@kbn/elastic-assistant-common'; + +import type { AIAssistantDataClientParams } from '..'; + +import { getDefendInsightsSearchEsMock } from '../../__mocks__/defend_insights_schema.mock'; +import { getDefendInsight } from './get_defend_insight'; +import { + queryParamsToEsQuery, + transformESSearchToDefendInsights, + transformToUpdateScheme, +} from './helpers'; +import { DefendInsightsDataClient } from '.'; + +jest.mock('./get_defend_insight'); +jest.mock('./helpers', () => { + const original = jest.requireActual('./helpers'); + return { + ...original, + queryParamsToEsQuery: jest.fn(), + }; +}); + +describe('DefendInsightsDataClient', () => { + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + const mockLogger = loggerMock.create(); + const mockGetDefendInsight = jest.mocked(getDefendInsight); + let user: AuthenticatedUser; + let dataClientParams: AIAssistantDataClientParams; + let dataClient: DefendInsightsDataClient; + + function getDefaultUser(): AuthenticatedUser { + return { + username: 'test_user', + profile_uid: '1234', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + } + + function getDefaultDataClientParams(): AIAssistantDataClientParams { + return { + logger: mockLogger, + currentUser: user, + elasticsearchClientPromise: new Promise((resolve) => resolve(mockEsClient)), + indexPatternsResourceName: 'defend-insights-index', + kibanaVersion: '9.0.0', + spaceId: 'space-1', + } as AIAssistantDataClientParams; + } + + beforeEach(() => { + user = getDefaultUser(); + dataClientParams = getDefaultDataClientParams(); + dataClient = new DefendInsightsDataClient(dataClientParams); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getDefendInsight', () => { + it('should correctly get defend insight', async () => { + const id = 'some-id'; + mockGetDefendInsight.mockResolvedValueOnce({ id } as DefendInsightsResponse); + const response = await dataClient.getDefendInsight({ id, authenticatedUser: user }); + + expect(mockGetDefendInsight).toHaveBeenCalledTimes(1); + expect(response).not.toBeNull(); + expect(response!.id).toEqual(id); + }); + }); + + describe('createDefendInsight', () => { + const defendInsightCreate: DefendInsightCreateProps = { + endpointIds: [], + insightType: DefendInsightType.Enum.incompatible_antivirus, + insights: [], + apiConfig: { + actionTypeId: 'action-type-id', + connectorId: 'connector-id', + defaultSystemPromptId: 'default-prompt-id', + model: 'model-name', + provider: 'OpenAI', + }, + eventsContextCount: 10, + replacements: { key1: 'value1', key2: 'value2' }, + status: DefendInsightStatus.Enum.running, + }; + + it('should create defend insight successfully', async () => { + const id = 'created-id'; + // @ts-expect-error not full response interface + mockEsClient.create.mockResolvedValueOnce({ _id: id }); + mockGetDefendInsight.mockResolvedValueOnce({ id } as DefendInsightsResponse); + + const response = await dataClient.createDefendInsight({ + defendInsightCreate, + authenticatedUser: user, + }); + expect(mockEsClient.create).toHaveBeenCalledTimes(1); + expect(mockGetDefendInsight).toHaveBeenCalledTimes(1); + expect(response).not.toBeNull(); + expect(response!.id).toEqual(id); + }); + + it('should throw error on elasticsearch create failure', async () => { + mockEsClient.create.mockRejectedValueOnce(new Error('Elasticsearch error')); + const responsePromise = dataClient.createDefendInsight({ + defendInsightCreate, + authenticatedUser: user, + }); + await expect(responsePromise).rejects.toThrowError('Elasticsearch error'); + expect(mockEsClient.create).toHaveBeenCalledTimes(1); + expect(mockGetDefendInsight).not.toHaveBeenCalled(); + }); + }); + + describe('findDefendInsightsByParams', () => { + let mockQueryParamsToEsQuery: Function; + let queryParams: DefendInsightsGetRequestQuery; + let expectedTermFilters: object[]; + + function getDefaultQueryParams() { + return { + ids: ['insight-id1', 'insight-id2'], + endpoint_ids: ['endpoint-id1', 'endpoint-id2'], + connector_id: 'connector-id1', + type: DefendInsightType.Enum.incompatible_antivirus, + status: DefendInsightStatus.Enum.succeeded, + }; + } + + function getDefaultExpectedTermFilters() { + return [ + { terms: { _id: queryParams.ids } }, + { terms: { endpoint_ids: queryParams.endpoint_ids } }, + { term: { 'api_config.connector_id': queryParams.connector_id } }, + { term: { insight_type: queryParams.type } }, + { term: { status: queryParams.status } }, + ]; + } + + beforeEach(() => { + queryParams = getDefaultQueryParams(); + expectedTermFilters = getDefaultExpectedTermFilters(); + mockQueryParamsToEsQuery = jest + .mocked(queryParamsToEsQuery) + .mockReturnValueOnce(expectedTermFilters); + }); + + it('should return defend insights successfully', async () => { + const mockResponse = getDefendInsightsSearchEsMock(); + mockEsClient.search.mockResolvedValueOnce(mockResponse); + + const result = await dataClient.findDefendInsightsByParams({ + params: queryParams, + authenticatedUser: user, + }); + const expectedResult = transformESSearchToDefendInsights(mockResponse); + + expect(mockQueryParamsToEsQuery).toHaveBeenCalledTimes(1); + expect(mockQueryParamsToEsQuery).toHaveBeenCalledWith(queryParams); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: `${dataClientParams.indexPatternsResourceName}-${dataClientParams.spaceId}`, + size: 10, + sort: [ + { + '@timestamp': 'desc', + }, + ], + query: { + bool: { + must: [ + ...expectedTermFilters, + { + nested: { + path: 'users', + query: { + bool: { + must: [ + { + match: { 'users.id': user.profile_uid }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }) + ); + expect(result).toEqual(expectedResult); + }); + + it('should log and throw an error if search fails', async () => { + const mockError = new Error('Search failed'); + mockEsClient.search.mockRejectedValue(mockError); + + await expect( + dataClient.findDefendInsightsByParams({ + params: queryParams, + authenticatedUser: user, + }) + ).rejects.toThrow(mockError); + expect(mockLogger.error).toHaveBeenCalledWith( + `error fetching Defend insights: ${mockError} with params: ${JSON.stringify(queryParams)}` + ); + }); + }); + + describe('findAllDefendInsights', () => { + it('should correctly query ES', async () => { + const mockResponse = getDefendInsightsSearchEsMock(); + mockEsClient.search.mockResolvedValueOnce(mockResponse); + const searchParams = { + query: { + bool: { + must: [ + { + nested: { + path: 'users', + query: { + bool: { + must: [ + { + match: { 'users.id': user.profile_uid }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + size: 10000, + _source: true, + ignore_unavailable: true, + index: `${dataClientParams.indexPatternsResourceName}-${dataClientParams.spaceId}`, + seq_no_primary_term: true, + }; + + const response = await dataClient.findAllDefendInsights({ + authenticatedUser: user, + }); + expect(response).not.toBeNull(); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledWith(searchParams); + }); + + it('should throw error on elasticsearch search failure', async () => { + mockEsClient.search.mockRejectedValueOnce(new Error('Elasticsearch error')); + await expect( + dataClient.findAllDefendInsights({ + authenticatedUser: user, + }) + ).rejects.toThrowError('Elasticsearch error'); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateDefendInsights', () => { + let defendInsightsUpdateProps: DefendInsightsUpdateProps; + + function getDefaultProps() { + return [ + { + id: 'insight-id1', + backingIndex: 'defend-insights-index', + status: DefendInsightStatus.Enum.succeeded, + insights: [ + { + group: 'windows_defender', + events: [ + { + id: 'event-id-1', + endpointId: 'endpoint-id-1', + value: '/windows/defender/scan.exe', + }, + ], + }, + ], + }, + ]; + } + + beforeEach(async () => { + defendInsightsUpdateProps = getDefaultProps(); + }); + + it('should update defend insights successfully', async () => { + // ensure startTime is before updatedAt timestamp + const startTime = new Date().getTime() - 1; + const mockResponse: DefendInsightsResponse[] = [ + { id: defendInsightsUpdateProps[0].id } as DefendInsightsResponse, + ]; + + const findDefendInsightsByParamsSpy = jest.spyOn(dataClient, 'findDefendInsightsByParams'); + findDefendInsightsByParamsSpy.mockResolvedValueOnce(mockResponse); + + const result = await dataClient.updateDefendInsights({ + defendInsightsUpdateProps, + authenticatedUser: user, + }); + const expectedDoc = transformToUpdateScheme('', defendInsightsUpdateProps[0]); + delete expectedDoc.updated_at; + + expect(mockEsClient.bulk).toHaveBeenCalledTimes(1); + expect(mockEsClient.bulk).toHaveBeenCalledWith({ + body: [ + { + update: { + _index: defendInsightsUpdateProps[0].backingIndex, + _id: defendInsightsUpdateProps[0].id, + }, + }, + { + doc: expect.objectContaining({ ...expectedDoc }), + }, + ], + refresh: 'wait_for', + }); + const updatedAt = (mockEsClient.bulk.mock.calls[0][0] as { body: any[] }).body[1].doc + .updated_at; + expect(new Date(updatedAt).getTime()).toBeGreaterThan(startTime); + expect(dataClient.findDefendInsightsByParams).toHaveBeenCalledTimes(1); + expect(dataClient.findDefendInsightsByParams).toHaveBeenCalledWith({ + params: { ids: [defendInsightsUpdateProps[0].id] }, + authenticatedUser: user, + }); + expect(result).toEqual(mockResponse); + }); + + it('should log a warning and throw an error if update fails', async () => { + const mockError = new Error('Update failed'); + mockEsClient.bulk.mockRejectedValue(mockError); + + await expect( + dataClient.updateDefendInsights({ + defendInsightsUpdateProps, + authenticatedUser: user, + }) + ).rejects.toThrow(mockError); + + expect(mockLogger.warn).toHaveBeenCalledWith( + `error updating Defend insights: ${mockError} for IDs: ${defendInsightsUpdateProps[0].id}` + ); + }); + }); + + describe('updateDefendInsight', () => { + it('correctly calls updateDefendInsights', async () => { + const defendInsightUpdateProps = { + id: 'insight-id1', + backingIndex: 'defend-insights-index', + status: DefendInsightStatus.Enum.succeeded, + insights: [ + { + group: 'windows_defender', + events: [ + { + id: 'event-id-1', + endpointId: 'endpoint-id-1', + value: '/windows/defender/scan.exe', + }, + ], + }, + ], + }; + const updateDefendInsightsSpy = jest.spyOn(dataClient, 'updateDefendInsights'); + updateDefendInsightsSpy.mockResolvedValueOnce([]); + await dataClient.updateDefendInsight({ + defendInsightUpdateProps, + authenticatedUser: user, + }); + + expect(updateDefendInsightsSpy).toHaveBeenCalledTimes(1); + expect(updateDefendInsightsSpy).toHaveBeenCalledWith({ + defendInsightsUpdateProps: [defendInsightUpdateProps], + authenticatedUser: user, + }); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.ts new file mode 100644 index 0000000000000..b5cbbd6cd18a2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/index.ts @@ -0,0 +1,286 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; + +import type { + DefendInsightCreateProps, + DefendInsightUpdateProps, + DefendInsightsUpdateProps, + DefendInsightsResponse, + DefendInsightsGetRequestQuery, +} from '@kbn/elastic-assistant-common'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; + +import type { AIAssistantDataClientParams } from '..'; +import type { EsDefendInsightSchema } from './types'; + +import { AIAssistantDataClient } from '..'; +import { getDefendInsight } from './get_defend_insight'; +import { + queryParamsToEsQuery, + transformESSearchToDefendInsights, + transformToCreateScheme, + transformToUpdateScheme, +} from './helpers'; + +const DEFAULT_PAGE_SIZE = 10; + +export class DefendInsightsDataClient extends AIAssistantDataClient { + constructor(public readonly options: AIAssistantDataClientParams) { + super(options); + } + + /** + * Fetches a Defend insight + * @param options + * @param options.id The existing Defend insight id. + * @param options.authenticatedUser Current authenticated user. + * @returns The Defend insight response + */ + public getDefendInsight = async ({ + id, + authenticatedUser, + }: { + id: string; + authenticatedUser: AuthenticatedUser; + }): Promise => { + const esClient = await this.options.elasticsearchClientPromise; + return getDefendInsight({ + esClient, + logger: this.options.logger, + index: this.indexTemplateAndPattern.alias, + id, + user: authenticatedUser, + }); + }; + + /** + * Creates a Defend insight, if given at least the "apiConfig" + * @param options + * @param options.defendInsightCreate + * @param options.authenticatedUser + * @returns The Defend insight created + */ + public createDefendInsight = async ({ + defendInsightCreate, + authenticatedUser, + }: { + defendInsightCreate: DefendInsightCreateProps; + authenticatedUser: AuthenticatedUser; + }): Promise => { + const esClient = await this.options.elasticsearchClientPromise; + const logger = this.options.logger; + const index = this.indexTemplateAndPattern.alias; + const user = authenticatedUser; + const id = defendInsightCreate?.id || uuidv4(); + const createdAt = new Date().toISOString(); + + const body = transformToCreateScheme(createdAt, this.spaceId, user, defendInsightCreate); + try { + const response = await esClient.create({ + body, + id, + index, + refresh: 'wait_for', + }); + + const createdDefendInsight = await getDefendInsight({ + esClient, + index, + id: response._id, + logger, + user, + }); + return createdDefendInsight; + } catch (err) { + logger.error(`error creating Defend insight: ${err} with id: ${id}`); + throw err; + } + }; + + /** + * Find Defend insights by params + * @param options + * @param options.params + * @param options.authenticatedUser + * @returns The Defend insights found + */ + public findDefendInsightsByParams = async ({ + params, + authenticatedUser, + }: { + params: DefendInsightsGetRequestQuery; + authenticatedUser: AuthenticatedUser; + }): Promise => { + const esClient = await this.options.elasticsearchClientPromise; + const logger = this.options.logger; + const index = this.indexTemplateAndPattern.alias; + const user = authenticatedUser; + const termFilters = queryParamsToEsQuery(params); + const filterByUser = [ + { + nested: { + path: 'users', + query: { + bool: { + must: [ + { + match: user.profile_uid + ? { 'users.id': user.profile_uid } + : { 'users.name': user.username }, + }, + ], + }, + }, + }, + }, + ]; + + try { + const query = { + bool: { + must: [...termFilters, ...filterByUser], + }, + }; + const response = await esClient.search({ + query, + _source: true, + ignore_unavailable: true, + index, + seq_no_primary_term: true, + sort: [{ '@timestamp': 'desc' }], + size: params.size || DEFAULT_PAGE_SIZE, + }); + return transformESSearchToDefendInsights(response); + } catch (err) { + logger.error(`error fetching Defend insights: ${err} with params: ${JSON.stringify(params)}`); + throw err; + } + }; + + /** + * Finds all Defend insight for authenticated user + * @param options + * @param options.authenticatedUser + * @returns The Defend insight + */ + public findAllDefendInsights = async ({ + authenticatedUser, + }: { + authenticatedUser: AuthenticatedUser; + }): Promise => { + const esClient = await this.options.elasticsearchClientPromise; + const logger = this.options.logger; + const index = this.indexTemplateAndPattern.alias; + const user = authenticatedUser; + const MAX_ITEMS = 10000; + const filterByUser = [ + { + nested: { + path: 'users', + query: { + bool: { + must: [ + { + match: user.profile_uid + ? { 'users.id': user.profile_uid } + : { 'users.name': user.username }, + }, + ], + }, + }, + }, + }, + ]; + + try { + const response = await esClient.search({ + query: { + bool: { + must: [...filterByUser], + }, + }, + size: MAX_ITEMS, + _source: true, + ignore_unavailable: true, + index, + seq_no_primary_term: true, + }); + const insights = transformESSearchToDefendInsights(response); + return insights ?? []; + } catch (err) { + logger.error(`error fetching Defend insights: ${err}`); + throw err; + } + }; + + /** + * Updates Defend insights + * @param options + * @param options.defendInsightsUpdateProps + * @param options.authenticatedUser + */ + public updateDefendInsights = async ({ + defendInsightsUpdateProps, + authenticatedUser, + }: { + defendInsightsUpdateProps: DefendInsightsUpdateProps; + authenticatedUser: AuthenticatedUser; + }): Promise => { + const esClient = await this.options.elasticsearchClientPromise; + const logger = this.options.logger; + const updatedAt = new Date().toISOString(); + + let ids: string[] = []; + const bulkParams = defendInsightsUpdateProps.flatMap((updateProp) => { + const index = updateProp.backingIndex; + const params = transformToUpdateScheme(updatedAt, updateProp); + ids = [...ids, params.id]; + return [ + { + update: { + _index: index, + _id: params.id, + }, + }, + { + doc: params, + }, + ]; + }); + + try { + await esClient.bulk({ body: bulkParams, refresh: 'wait_for' }); + return this.findDefendInsightsByParams({ params: { ids }, authenticatedUser }); + } catch (err) { + logger.warn(`error updating Defend insights: ${err} for IDs: ${ids}`); + throw err; + } + }; + + /** + * Updates a Defend insight + * @param options + * @param options.defendInsightUpdateProps + * @param options.authenticatedUser + */ + public updateDefendInsight = async ({ + defendInsightUpdateProps, + authenticatedUser, + }: { + defendInsightUpdateProps: DefendInsightUpdateProps; + authenticatedUser: AuthenticatedUser; + }): Promise => { + return ( + await this.updateDefendInsights({ + defendInsightsUpdateProps: [defendInsightUpdateProps], + authenticatedUser, + }) + )[0]; + }; +} diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/types.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/types.ts new file mode 100644 index 0000000000000..f04c7ef505c2f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/defend_insights/types.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 type { + DefendInsightStatus, + DefendInsightType, + Provider, + UUID, +} from '@kbn/elastic-assistant-common'; + +import type { EsReplacementSchema } from '../conversations/types'; + +interface DefendInsightInsightEventSchema { + id: string; + endpoint_id: string; + value: string; +} + +interface DefendInsightInsightSchema { + group: string; + events?: DefendInsightInsightEventSchema[]; +} + +interface BaseDefendInsightSchema { + '@timestamp': string; + created_at: string; + updated_at: string; + last_viewed_at: string; + status: DefendInsightStatus; + events_context_count?: number; + endpoint_ids: string[]; + insight_type: DefendInsightType; + insights: DefendInsightInsightSchema[]; + api_config: { + connector_id: string; + action_type_id: string; + default_system_prompt_id?: string; + provider?: Provider; + model?: string; + }; + replacements?: EsReplacementSchema[]; +} + +export interface EsDefendInsightSchema extends BaseDefendInsightSchema { + id: string; + namespace: string; + failure_reason?: string; + users?: Array<{ + id?: string; + name?: string; + }>; + average_interval_ms?: number; + generation_intervals?: Array<{ date: string; duration_ms: number }>; +} + +export interface CreateDefendInsightSchema extends BaseDefendInsightSchema { + id?: string | undefined; + users: Array<{ + id?: string; + name?: string; + }>; + namespace: string; +} + +export interface UpdateDefendInsightSchema { + id: UUID; + '@timestamp'?: string; + updated_at?: string; + last_viewed_at?: string; + status?: DefendInsightStatus; + events_context_count?: number; + insights?: DefendInsightInsightSchema[]; + api_config?: { + action_type_id?: string; + connector_id?: string; + default_system_prompt_id?: string; + provider?: Provider; + model?: string; + }; + replacements?: EsReplacementSchema[]; + average_interval_ms?: number; + generation_intervals?: Array<{ date: string; duration_ms: number }>; + failure_reason?: string; +} diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts index fb3ffe7442c17..4bfd4da6cfcbf 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts @@ -141,7 +141,7 @@ describe('AI Assistant Service', () => { expect(assistantService.isInitialized()).toEqual(true); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(5); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(6); const expectedTemplates = [ '.kibana-elastic-ai-assistant-component-template-conversations', @@ -149,6 +149,7 @@ describe('AI Assistant Service', () => { '.kibana-elastic-ai-assistant-component-template-prompts', '.kibana-elastic-ai-assistant-component-template-anonymization-fields', '.kibana-elastic-ai-assistant-component-template-attack-discovery', + '.kibana-elastic-ai-assistant-component-template-defend-insights', ]; expectedTemplates.forEach((t, i) => { expect(clusterClient.cluster.putComponentTemplate.mock.calls[i][0].name).toEqual(t); @@ -650,7 +651,7 @@ describe('AI Assistant Service', () => { 'AI Assistant service initialized', async () => assistantService.isInitialized() === true ); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(7); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(8); const expectedTemplates = [ '.kibana-elastic-ai-assistant-component-template-conversations', @@ -660,6 +661,7 @@ describe('AI Assistant Service', () => { '.kibana-elastic-ai-assistant-component-template-prompts', '.kibana-elastic-ai-assistant-component-template-anonymization-fields', '.kibana-elastic-ai-assistant-component-template-attack-discovery', + '.kibana-elastic-ai-assistant-component-template-defend-insights', ]; expectedTemplates.forEach((t, i) => { expect(clusterClient.cluster.putComponentTemplate.mock.calls[i][0].name).toEqual(t); @@ -684,7 +686,7 @@ describe('AI Assistant Service', () => { async () => (await getSpaceResourcesInitialized(assistantService)) === true ); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(7); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(8); const expectedTemplates = [ '.kibana-elastic-ai-assistant-index-template-conversations', '.kibana-elastic-ai-assistant-index-template-conversations', @@ -693,6 +695,7 @@ describe('AI Assistant Service', () => { '.kibana-elastic-ai-assistant-index-template-prompts', '.kibana-elastic-ai-assistant-index-template-anonymization-fields', '.kibana-elastic-ai-assistant-index-template-attack-discovery', + '.kibana-elastic-ai-assistant-index-template-defend-insights', ]; expectedTemplates.forEach((t, i) => { expect(clusterClient.indices.putIndexTemplate.mock.calls[i][0].name).toEqual(t); @@ -716,7 +719,7 @@ describe('AI Assistant Service', () => { async () => (await getSpaceResourcesInitialized(assistantService)) === true ); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(7); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(8); }); test('should retry updating index mappings for existing indices for transient ES errors', async () => { @@ -736,7 +739,7 @@ describe('AI Assistant Service', () => { async () => (await getSpaceResourcesInitialized(assistantService)) === true ); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(7); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(8); }); test('should retry creating concrete index for transient ES errors', async () => { diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index d7eff095b4be5..81ddd69fb67d3 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -13,6 +13,7 @@ import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import { Subject } from 'rxjs'; import { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server'; import { attackDiscoveryFieldMap } from '../lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration'; +import { defendInsightsFieldMap } from '../ai_assistant_data_clients/defend_insights/field_maps_configuration'; import { getDefaultAnonymizationFields } from '../../common/anonymization'; import { AssistantResourceNames, GetElser } from '../types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; @@ -33,6 +34,7 @@ import { GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; +import { DefendInsightsDataClient } from '../ai_assistant_data_clients/defend_insights'; import { createGetElserId, createPipeline, pipelineExists } from './helpers'; import { hasAIAssistantLicense } from '../routes/helpers'; @@ -64,7 +66,8 @@ export type CreateDataStream = (params: { | 'conversations' | 'knowledgeBase' | 'prompts' - | 'attackDiscovery'; + | 'attackDiscovery' + | 'defendInsights'; fieldMap: FieldMap; kibanaVersion: string; spaceId?: string; @@ -79,6 +82,7 @@ export class AIAssistantService { private promptsDataStream: DataStreamSpacesAdapter; private anonymizationFieldsDataStream: DataStreamSpacesAdapter; private attackDiscoveryDataStream: DataStreamSpacesAdapter; + private defendInsightsDataStream: DataStreamSpacesAdapter; private resourceInitializationHelper: ResourceInstallationHelper; private initPromise: Promise; private isKBSetupInProgress: boolean = false; @@ -112,6 +116,11 @@ export class AIAssistantService { kibanaVersion: options.kibanaVersion, fieldMap: attackDiscoveryFieldMap, }); + this.defendInsightsDataStream = this.createDataStream({ + resource: 'defendInsights', + kibanaVersion: options.kibanaVersion, + fieldMap: defendInsightsFieldMap, + }); this.initPromise = this.initializeResources(); @@ -222,6 +231,12 @@ export class AIAssistantService { logger: this.options.logger, pluginStop$: this.options.pluginStop$, }); + + await this.defendInsightsDataStream.install({ + esClient, + logger: this.options.logger, + pluginStop$: this.options.pluginStop$, + }); } catch (error) { this.options.logger.warn(`Error initializing AI assistant resources: ${error.message}`); this.initialized = false; @@ -240,6 +255,7 @@ export class AIAssistantService { prompts: getResourceName('component-template-prompts'), anonymizationFields: getResourceName('component-template-anonymization-fields'), attackDiscovery: getResourceName('component-template-attack-discovery'), + defendInsights: getResourceName('component-template-defend-insights'), }, aliases: { conversations: getResourceName('conversations'), @@ -247,6 +263,7 @@ export class AIAssistantService { prompts: getResourceName('prompts'), anonymizationFields: getResourceName('anonymization-fields'), attackDiscovery: getResourceName('attack-discovery'), + defendInsights: getResourceName('defend-insights'), }, indexPatterns: { conversations: getResourceName('conversations*'), @@ -254,6 +271,7 @@ export class AIAssistantService { prompts: getResourceName('prompts*'), anonymizationFields: getResourceName('anonymization-fields*'), attackDiscovery: getResourceName('attack-discovery*'), + defendInsights: getResourceName('defend-insights*'), }, indexTemplate: { conversations: getResourceName('index-template-conversations'), @@ -261,6 +279,7 @@ export class AIAssistantService { prompts: getResourceName('index-template-prompts'), anonymizationFields: getResourceName('index-template-anonymization-fields'), attackDiscovery: getResourceName('index-template-attack-discovery'), + defendInsights: getResourceName('index-template-defend-insights'), }, pipelines: { knowledgeBase: getResourceName('ingest-pipeline-knowledge-base'), @@ -393,6 +412,25 @@ export class AIAssistantService { }); } + public async createDefendInsightsDataClient( + opts: CreateAIAssistantClientParams + ): Promise { + const res = await this.checkResourcesInstallation(opts); + + if (res === null) { + return null; + } + + return new DefendInsightsDataClient({ + logger: this.options.logger.get('defendInsights'), + currentUser: opts.currentUser, + elasticsearchClientPromise: this.options.elasticsearchClientPromise, + indexPatternsResourceName: this.resourceNames.aliases.defendInsights, + kibanaVersion: this.options.kibanaVersion, + spaceId: opts.spaceId, + }); + } + public async createAIAssistantPromptsDataClient( opts: CreateAIAssistantClientParams ): Promise { diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts index 133419f45d175..9b2d444d643e4 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts @@ -6,7 +6,7 @@ */ import { KibanaRequest } from '@kbn/core-http-server'; -import type { Message } from '@kbn/elastic-assistant-common'; +import type { DefendInsightsPostRequestBody, Message } from '@kbn/elastic-assistant-common'; import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages'; import { AttackDiscoveryPostRequestBody, @@ -36,7 +36,7 @@ export const requestHasRequiredAnonymizationParams = ( request: KibanaRequest< unknown, unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody | DefendInsightsPostRequestBody > ): boolean => { const { replacements } = request?.body ?? {}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/telemetry/event_based_telemetry.ts b/x-pack/plugins/elastic_assistant/server/lib/telemetry/event_based_telemetry.ts index 1087703ba13a4..92330b4960e76 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/telemetry/event_based_telemetry.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/telemetry/event_based_telemetry.ts @@ -411,6 +411,100 @@ export const CREATE_KNOWLEDGE_BASE_ENTRY_ERROR_EVENT: EventTypeOpts<{ }, }; +export const DEFEND_INSIGHT_SUCCESS_EVENT: EventTypeOpts<{ + actionTypeId: string; + eventsContextCount: number; + insightsGenerated: number; + durationMs: number; + model?: string; + provider?: string; +}> = { + eventType: 'defend_insight_success', + schema: { + actionTypeId: { + type: 'keyword', + _meta: { + description: 'Kibana connector type', + optional: false, + }, + }, + eventsContextCount: { + type: 'integer', + _meta: { + description: 'Number of events sent as context to the LLM', + optional: false, + }, + }, + insightsGenerated: { + type: 'integer', + _meta: { + description: 'Quantity of Defend insights generated', + optional: false, + }, + }, + durationMs: { + type: 'integer', + _meta: { + description: 'Duration of request in ms', + optional: false, + }, + }, + model: { + type: 'keyword', + _meta: { + description: 'LLM model', + optional: true, + }, + }, + provider: { + type: 'keyword', + _meta: { + description: 'OpenAI provider', + optional: true, + }, + }, + }, +}; + +export const DEFEND_INSIGHT_ERROR_EVENT: EventTypeOpts<{ + actionTypeId: string; + errorMessage: string; + model?: string; + provider?: string; +}> = { + eventType: 'defend_insight_error', + schema: { + actionTypeId: { + type: 'keyword', + _meta: { + description: 'Kibana connector type', + optional: false, + }, + }, + errorMessage: { + type: 'keyword', + _meta: { + description: 'Error message from Elasticsearch', + }, + }, + + model: { + type: 'keyword', + _meta: { + description: 'LLM model', + optional: true, + }, + }, + provider: { + type: 'keyword', + _meta: { + description: 'OpenAI provider', + optional: true, + }, + }, + }, +}; + export const events: Array> = [ KNOWLEDGE_BASE_EXECUTION_SUCCESS_EVENT, KNOWLEDGE_BASE_EXECUTION_ERROR_EVENT, @@ -420,4 +514,6 @@ export const events: Array> = [ INVOKE_ASSISTANT_ERROR_EVENT, ATTACK_DISCOVERY_SUCCESS_EVENT, ATTACK_DISCOVERY_ERROR_EVENT, + DEFEND_INSIGHT_SUCCESS_EVENT, + DEFEND_INSIGHT_ERROR_EVENT, ]; diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.test.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.test.ts new file mode 100644 index 0000000000000..fa3ff15027e23 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { AuthenticatedUser } from '@kbn/core-security-common'; + +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; + +import type { DefendInsightsDataClient } from '../../ai_assistant_data_clients/defend_insights'; + +import { transformESSearchToDefendInsights } from '../../ai_assistant_data_clients/defend_insights/helpers'; +import { getDefendInsightsSearchEsMock } from '../../__mocks__/defend_insights_schema.mock'; +import { getDefendInsightRequest } from '../../__mocks__/request'; +import { + ElasticAssistantRequestHandlerContextMock, + requestContextMock, +} from '../../__mocks__/request_context'; +import { serverMock } from '../../__mocks__/server'; +import { isDefendInsightsEnabled, updateDefendInsightLastViewedAt } from './helpers'; +import { getDefendInsightRoute } from './get_defend_insight'; + +jest.mock('./helpers'); + +describe('getDefendInsightRoute', () => { + let server: ReturnType; + let context: ElasticAssistantRequestHandlerContextMock; + let mockUser: AuthenticatedUser; + let mockDataClient: DefendInsightsDataClient; + let mockCurrentInsight: any; + + function getDefaultUser(): AuthenticatedUser { + return { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + } + + function getDefaultDataClient(): DefendInsightsDataClient { + return { + findDefendInsightByConnectorId: jest.fn(), + updateDefendInsight: jest.fn(), + createDefendInsight: jest.fn(), + getDefendInsight: jest.fn(), + } as unknown as DefendInsightsDataClient; + } + + beforeEach(() => { + const tools = requestContextMock.createTools(); + context = tools.context; + server = serverMock.create(); + tools.clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient(); + + mockUser = getDefaultUser(); + mockDataClient = getDefaultDataClient(); + mockCurrentInsight = transformESSearchToDefendInsights(getDefendInsightsSearchEsMock())[0]; + + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser); + context.elasticAssistant.getDefendInsightsDataClient.mockResolvedValue(mockDataClient); + getDefendInsightRoute(server.router); + (updateDefendInsightLastViewedAt as jest.Mock).mockResolvedValue(mockCurrentInsight); + (isDefendInsightsEnabled as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should handle successful request', async () => { + const response = await server.inject( + getDefendInsightRequest('insight-id1'), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + data: mockCurrentInsight, + }); + }); + + it('should 404 if feature flag disabled', async () => { + (isDefendInsightsEnabled as jest.Mock).mockReturnValueOnce(false); + const response = await server.inject( + getDefendInsightRequest('insight-id1'), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(404); + }); + + it('should handle missing authenticated user', async () => { + context.elasticAssistant.getCurrentUser.mockReturnValueOnce(null); + const response = await server.inject( + getDefendInsightRequest('insight-id1'), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(401); + expect(response.body).toEqual({ + message: 'Authenticated user not found', + status_code: 401, + }); + }); + + it('should handle missing data client', async () => { + context.elasticAssistant.getDefendInsightsDataClient.mockResolvedValueOnce(null); + const response = await server.inject( + getDefendInsightRequest('insight-id1'), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Defend insights data client not initialized', + status_code: 500, + }); + }); + + it('should handle updateDefendInsightLastViewedAt empty array', async () => { + (updateDefendInsightLastViewedAt as jest.Mock).mockResolvedValueOnce([]); + const response = await server.inject( + getDefendInsightRequest('insight-id1'), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ data: [] }); + }); + + it('should handle updateDefendInsightLastViewedAt error', async () => { + (updateDefendInsightLastViewedAt as jest.Mock).mockRejectedValueOnce(new Error('Oh no!')); + const response = await server.inject( + getDefendInsightRequest('insight-id1'), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: { + error: 'Oh no!', + success: false, + }, + status_code: 500, + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.ts new file mode 100644 index 0000000000000..5766b3d1b014b --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insight.ts @@ -0,0 +1,96 @@ +/* + * 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 { IKibanaResponse } from '@kbn/core/server'; + +import { IRouter, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { + DEFEND_INSIGHTS_BY_ID, + DefendInsightGetResponse, + DefendInsightGetRequestParams, + ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, +} from '@kbn/elastic-assistant-common'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { isDefendInsightsEnabled, updateDefendInsightLastViewedAt } from './helpers'; + +export const getDefendInsightRoute = (router: IRouter) => { + router.versioned + .get({ + access: 'internal', + path: DEFEND_INSIGHTS_BY_ID, + options: { + tags: ['access:elasticAssistant'], + }, + }) + .addVersion( + { + version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + validate: { + request: { + params: buildRouteValidationWithZod(DefendInsightGetRequestParams), + }, + response: { + 200: { + body: { custom: buildRouteValidationWithZod(DefendInsightGetResponse) }, + }, + }, + }, + }, + async (context, request, response): Promise> => { + const resp = buildResponse(response); + const assistantContext = await context.elasticAssistant; + const logger: Logger = assistantContext.logger; + try { + const isEnabled = isDefendInsightsEnabled({ + request, + logger, + assistantContext, + }); + if (!isEnabled) { + return response.notFound(); + } + + const dataClient = await assistantContext.getDefendInsightsDataClient(); + const authenticatedUser = assistantContext.getCurrentUser(); + if (authenticatedUser == null) { + return resp.error({ + body: `Authenticated user not found`, + statusCode: 401, + }); + } + if (!dataClient) { + return resp.error({ + body: `Defend insights data client not initialized`, + statusCode: 500, + }); + } + + const defendInsight = await updateDefendInsightLastViewedAt({ + dataClient, + id: request.params.id, + authenticatedUser, + }); + + return response.ok({ + body: { data: defendInsight }, + }); + } catch (err) { + logger.error(err); + const error = transformError(err); + + return resp.error({ + body: { success: false, error: error.message }, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.test.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.test.ts new file mode 100644 index 0000000000000..b27d71a690b6c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { AuthenticatedUser } from '@kbn/core-security-common'; + +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; + +import type { DefendInsightsDataClient } from '../../ai_assistant_data_clients/defend_insights'; + +import { transformESSearchToDefendInsights } from '../../ai_assistant_data_clients/defend_insights/helpers'; +import { getDefendInsightsSearchEsMock } from '../../__mocks__/defend_insights_schema.mock'; +import { getDefendInsightsRequest } from '../../__mocks__/request'; +import { + ElasticAssistantRequestHandlerContextMock, + requestContextMock, +} from '../../__mocks__/request_context'; +import { serverMock } from '../../__mocks__/server'; +import { isDefendInsightsEnabled, updateDefendInsightsLastViewedAt } from './helpers'; +import { getDefendInsightsRoute } from './get_defend_insights'; + +jest.mock('./helpers'); + +describe('getDefendInsightsRoute', () => { + let server: ReturnType; + let context: ElasticAssistantRequestHandlerContextMock; + let mockUser: AuthenticatedUser; + let mockDataClient: DefendInsightsDataClient; + let mockCurrentInsights: any; + + function getDefaultUser(): AuthenticatedUser { + return { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + } + + function getDefaultDataClient(): DefendInsightsDataClient { + return { + findDefendInsightByConnectorId: jest.fn(), + updateDefendInsight: jest.fn(), + createDefendInsight: jest.fn(), + getDefendInsight: jest.fn(), + } as unknown as DefendInsightsDataClient; + } + + beforeEach(() => { + const tools = requestContextMock.createTools(); + context = tools.context; + server = serverMock.create(); + tools.clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient(); + + mockUser = getDefaultUser(); + mockDataClient = getDefaultDataClient(); + mockCurrentInsights = transformESSearchToDefendInsights(getDefendInsightsSearchEsMock()); + + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser); + context.elasticAssistant.getDefendInsightsDataClient.mockResolvedValue(mockDataClient); + getDefendInsightsRoute(server.router); + (updateDefendInsightsLastViewedAt as jest.Mock).mockResolvedValue(mockCurrentInsights); + (isDefendInsightsEnabled as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should handle successful request', async () => { + const response = await server.inject( + getDefendInsightsRequest({ connector_id: 'connector-id1' }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + data: mockCurrentInsights, + }); + }); + + it('should 404 if feature flag disabled', async () => { + (isDefendInsightsEnabled as jest.Mock).mockReturnValueOnce(false); + const response = await server.inject( + getDefendInsightsRequest({ connector_id: 'connector-id1' }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(404); + }); + + it('should handle missing authenticated user', async () => { + context.elasticAssistant.getCurrentUser.mockReturnValueOnce(null); + const response = await server.inject( + getDefendInsightsRequest({ connector_id: 'connector-id1' }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(401); + expect(response.body).toEqual({ + message: 'Authenticated user not found', + status_code: 401, + }); + }); + + it('should handle missing data client', async () => { + context.elasticAssistant.getDefendInsightsDataClient.mockResolvedValueOnce(null); + const response = await server.inject( + getDefendInsightsRequest({ connector_id: 'connector-id1' }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Defend insights data client not initialized', + status_code: 500, + }); + }); + + it('should handle updateDefendInsightsLastViewedAt empty array', async () => { + (updateDefendInsightsLastViewedAt as jest.Mock).mockResolvedValueOnce([]); + const response = await server.inject( + getDefendInsightsRequest({ connector_id: 'connector-id1' }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ data: [] }); + }); + + it('should handle updateDefendInsightsLastViewedAt error', async () => { + (updateDefendInsightsLastViewedAt as jest.Mock).mockRejectedValueOnce(new Error('Oh no!')); + const response = await server.inject( + getDefendInsightsRequest({ connector_id: 'connector-id1' }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: { + error: 'Oh no!', + success: false, + }, + status_code: 500, + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.ts new file mode 100644 index 0000000000000..e980c9be0915d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/get_defend_insights.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 type { IKibanaResponse } from '@kbn/core/server'; + +import { IRouter, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { + DEFEND_INSIGHTS, + DefendInsightsGetResponse, + DefendInsightsGetRequestQuery, + ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, +} from '@kbn/elastic-assistant-common'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { isDefendInsightsEnabled, updateDefendInsightsLastViewedAt } from './helpers'; + +export const getDefendInsightsRoute = (router: IRouter) => { + router.versioned + .get({ + access: 'internal', + path: DEFEND_INSIGHTS, + options: { + tags: ['access:elasticAssistant'], + }, + }) + .addVersion( + { + version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + validate: { + request: { + query: buildRouteValidationWithZod(DefendInsightsGetRequestQuery), + }, + response: { + 200: { + body: { custom: buildRouteValidationWithZod(DefendInsightsGetResponse) }, + }, + }, + }, + }, + async (context, request, response): Promise> => { + const resp = buildResponse(response); + const assistantContext = await context.elasticAssistant; + const logger: Logger = assistantContext.logger; + try { + const isEnabled = isDefendInsightsEnabled({ + request, + logger, + assistantContext, + }); + if (!isEnabled) { + return response.notFound(); + } + + const dataClient = await assistantContext.getDefendInsightsDataClient(); + + const authenticatedUser = assistantContext.getCurrentUser(); + if (authenticatedUser == null) { + return resp.error({ + body: `Authenticated user not found`, + statusCode: 401, + }); + } + if (!dataClient) { + return resp.error({ + body: `Defend insights data client not initialized`, + statusCode: 500, + }); + } + + const defendInsights = await updateDefendInsightsLastViewedAt({ + dataClient, + params: request.query, + authenticatedUser, + }); + return response.ok({ + body: { + data: defendInsights, + }, + }); + } catch (err) { + logger.error(err); + const error = transformError(err); + + return resp.error({ + body: { success: false, error: error.message }, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.test.ts new file mode 100644 index 0000000000000..22e89202e638b --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.test.ts @@ -0,0 +1,255 @@ +/* + * 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 @typescript-eslint/no-explicit-any */ + +import moment from 'moment'; + +import { + DEFEND_INSIGHTS_TOOL_ID, + DefendInsightStatus, + DefendInsightType, +} from '@kbn/elastic-assistant-common'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; + +import { + DEFEND_INSIGHT_ERROR_EVENT, + DEFEND_INSIGHT_SUCCESS_EVENT, +} from '../../lib/telemetry/event_based_telemetry'; +import { + getAssistantTool, + getAssistantToolParams, + handleToolError, + updateDefendInsights, + updateDefendInsightLastViewedAt, +} from './helpers'; + +describe('defend insights route helpers', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getAssistantTool', () => { + it('should return the defend-insights tool', () => { + const getRegisteredTools = jest.fn().mockReturnValue([{ id: DEFEND_INSIGHTS_TOOL_ID }]); + const result = getAssistantTool(getRegisteredTools, 'pluginName'); + expect(result).toEqual({ id: DEFEND_INSIGHTS_TOOL_ID }); + }); + }); + + describe('getAssistantToolParams', () => { + it('should return the correct tool params', () => { + const params = { + endpointIds: ['endpoint-id1'], + insightType: DefendInsightType.Enum.incompatible_antivirus, + actionsClient: {} as any, + anonymizationFields: [], + apiConfig: { connectorId: 'connector-id1', actionTypeId: 'action-type-id1' }, + esClient: {} as any, + connectorTimeout: 1000, + langChainTimeout: 1000, + langSmithProject: 'project', + langSmithApiKey: 'apiKey', + logger: {} as any, + latestReplacements: {}, + onNewReplacements: jest.fn(), + request: {} as any, + }; + const result = getAssistantToolParams(params); + + expect(result).toHaveProperty('endpointIds', params.endpointIds); + expect(result).toHaveProperty('insightType', params.insightType); + expect(result).toHaveProperty('llm'); + }); + }); + + describe('handleToolError', () => { + it('should handle tool error and update defend insight', async () => { + const params = { + apiConfig: { + connectorId: 'connector-id1', + actionTypeId: 'action-type-id1', + model: 'model', + provider: OpenAiProviderType.OpenAi, + }, + defendInsightId: 'id', + authenticatedUser: {} as any, + dataClient: { + getDefendInsight: jest.fn().mockResolvedValueOnce({ + status: DefendInsightStatus.Enum.running, + backingIndex: 'index', + }), + updateDefendInsight: jest.fn(), + } as any, + err: new Error('error'), + latestReplacements: {}, + logger: { error: jest.fn() } as any, + telemetry: { reportEvent: jest.fn() } as any, + }; + await handleToolError(params); + + expect(params.dataClient.updateDefendInsight).toHaveBeenCalledTimes(1); + expect(params.telemetry.reportEvent).toHaveBeenCalledWith( + DEFEND_INSIGHT_ERROR_EVENT.eventType, + expect.any(Object) + ); + }); + }); + + describe('updateDefendInsights', () => { + it('should update defend insights', async () => { + const params = { + apiConfig: { + connectorId: 'connector-id1', + actionTypeId: 'action-type-id1', + model: 'model', + provider: OpenAiProviderType.OpenAi, + }, + defendInsightId: 'insight-id1', + authenticatedUser: {} as any, + dataClient: { + getDefendInsight: jest.fn().mockResolvedValueOnce({ + status: DefendInsightStatus.Enum.running, + backingIndex: 'backing-index-name', + generationIntervals: [], + }), + updateDefendInsight: jest.fn(), + } as any, + latestReplacements: {}, + logger: { error: jest.fn() } as any, + rawDefendInsights: '{"eventsContextCount": 5, "insights": ["insight1", "insight2"]}', + startTime: moment(), + telemetry: { reportEvent: jest.fn() } as any, + }; + await updateDefendInsights(params); + + expect(params.dataClient.getDefendInsight).toHaveBeenCalledTimes(1); + expect(params.dataClient.getDefendInsight).toHaveBeenCalledWith({ + id: params.defendInsightId, + authenticatedUser: params.authenticatedUser, + }); + expect(params.dataClient.updateDefendInsight).toHaveBeenCalledTimes(1); + expect(params.dataClient.updateDefendInsight).toHaveBeenCalledWith({ + defendInsightUpdateProps: { + eventsContextCount: 5, + insights: ['insight1', 'insight2'], + status: DefendInsightStatus.Enum.succeeded, + generationIntervals: expect.arrayContaining([ + expect.objectContaining({ + date: expect.any(String), + durationMs: expect.any(Number), + }), + ]), + id: params.defendInsightId, + replacements: params.latestReplacements, + backingIndex: 'backing-index-name', + }, + authenticatedUser: params.authenticatedUser, + }); + expect(params.telemetry.reportEvent).toHaveBeenCalledWith( + DEFEND_INSIGHT_SUCCESS_EVENT.eventType, + expect.any(Object) + ); + }); + + it('should handle error if rawDefendInsights is null', async () => { + const params = { + apiConfig: { + connectorId: 'connector-id1', + actionTypeId: 'action-type-id1', + model: 'model', + provider: OpenAiProviderType.OpenAi, + }, + defendInsightId: 'id', + authenticatedUser: {} as any, + dataClient: { + getDefendInsight: jest.fn().mockResolvedValueOnce({ + status: DefendInsightStatus.Enum.running, + backingIndex: 'index', + generationIntervals: [], + }), + updateDefendInsight: jest.fn(), + } as any, + latestReplacements: {}, + logger: { error: jest.fn() } as any, + rawDefendInsights: null, + startTime: moment(), + telemetry: { reportEvent: jest.fn() } as any, + }; + await updateDefendInsights(params); + + expect(params.logger.error).toHaveBeenCalledTimes(1); + expect(params.telemetry.reportEvent).toHaveBeenCalledTimes(1); + expect(params.telemetry.reportEvent).toHaveBeenCalledWith( + DEFEND_INSIGHT_ERROR_EVENT.eventType, + expect.any(Object) + ); + }); + }); + + describe('updateDefendInsightLastViewedAt', () => { + it('should update lastViewedAt time', async () => { + // ensure difference regardless of processing speed + const startTime = new Date().getTime() - 1; + const insightId = 'defend-insight-id1'; + const backingIndex = 'backing-index'; + const params = { + id: insightId, + authenticatedUser: {} as any, + dataClient: { + findDefendInsightsByParams: jest + .fn() + .mockResolvedValueOnce([{ id: insightId, backingIndex }]), + updateDefendInsights: jest.fn().mockResolvedValueOnce([{ id: insightId }]), + } as any, + }; + const result = await updateDefendInsightLastViewedAt(params); + + expect(params.dataClient.findDefendInsightsByParams).toHaveBeenCalledTimes(1); + expect(params.dataClient.findDefendInsightsByParams).toHaveBeenCalledWith({ + params: { ids: [insightId] }, + authenticatedUser: params.authenticatedUser, + }); + expect(params.dataClient.updateDefendInsights).toHaveBeenCalledTimes(1); + expect(params.dataClient.updateDefendInsights).toHaveBeenCalledWith({ + defendInsightsUpdateProps: [ + expect.objectContaining({ + id: insightId, + backingIndex, + }), + ], + authenticatedUser: params.authenticatedUser, + }); + expect( + new Date( + params.dataClient.updateDefendInsights.mock.calls[0][0].defendInsightsUpdateProps[0].lastViewedAt + ).getTime() + ).toBeGreaterThan(startTime); + expect(result).toEqual({ id: insightId }); + }); + + it('should return undefined if defend insight not found', async () => { + const insightId = 'defend-insight-id1'; + const params = { + id: insightId, + authenticatedUser: {} as any, + dataClient: { + findDefendInsightsByParams: jest.fn().mockResolvedValueOnce([]), + updateDefendInsight: jest.fn(), + } as any, + }; + const result = await updateDefendInsightLastViewedAt(params); + + expect(params.dataClient.findDefendInsightsByParams).toHaveBeenCalledTimes(1); + expect(params.dataClient.findDefendInsightsByParams).toHaveBeenCalledWith({ + params: { ids: [insightId] }, + authenticatedUser: params.authenticatedUser, + }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.ts new file mode 100644 index 0000000000000..e67f00ef6514c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/helpers.ts @@ -0,0 +1,387 @@ +/* + * 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 moment, { Moment } from 'moment'; + +import type { + AnalyticsServiceSetup, + AuthenticatedUser, + KibanaRequest, + Logger, +} from '@kbn/core/server'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { + ApiConfig, + DefendInsight, + DefendInsightGenerationInterval, + DefendInsightsPostRequestBody, + DefendInsightsResponse, + Replacements, +} from '@kbn/elastic-assistant-common'; +import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; + +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { + DEFEND_INSIGHTS_TOOL_ID, + DefendInsightStatus, + DefendInsightType, + DefendInsightsGetRequestQuery, +} from '@kbn/elastic-assistant-common'; + +import type { GetRegisteredTools } from '../../services/app_context'; +import type { AssistantTool, ElasticAssistantApiRequestHandlerContext } from '../../types'; + +import { DefendInsightsDataClient } from '../../ai_assistant_data_clients/defend_insights'; +import { + DEFEND_INSIGHT_ERROR_EVENT, + DEFEND_INSIGHT_SUCCESS_EVENT, +} from '../../lib/telemetry/event_based_telemetry'; +import { getLlmType } from '../utils'; +import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; + +function getDataFromJSON(defendInsightStringified: string): { + eventsContextCount: number; + insights: DefendInsight[]; +} { + const { eventsContextCount, insights } = JSON.parse(defendInsightStringified); + return { eventsContextCount, insights }; +} + +function addGenerationInterval( + generationIntervals: DefendInsightGenerationInterval[], + generationInterval: DefendInsightGenerationInterval +): DefendInsightGenerationInterval[] { + const newGenerationIntervals = [generationInterval, ...generationIntervals]; + + const MAX_GENERATION_INTERVALS = 5; + if (newGenerationIntervals.length > MAX_GENERATION_INTERVALS) { + return newGenerationIntervals.slice(0, MAX_GENERATION_INTERVALS); // Return the first MAX_GENERATION_INTERVALS items + } + + return newGenerationIntervals; +} + +export function isDefendInsightsEnabled({ + request, + logger, + assistantContext, +}: { + request: KibanaRequest; + logger: Logger; + assistantContext: ElasticAssistantApiRequestHandlerContext; +}): boolean { + const pluginName = getPluginNameFromRequest({ + request, + logger, + defaultPluginName: DEFAULT_PLUGIN_NAME, + }); + + return assistantContext.getRegisteredFeatures(pluginName).defendInsights; +} + +export function getAssistantTool( + getRegisteredTools: GetRegisteredTools, + pluginName: string +): AssistantTool | undefined { + const assistantTools = getRegisteredTools(pluginName); + return assistantTools.find((tool) => tool.id === DEFEND_INSIGHTS_TOOL_ID); +} + +export function getAssistantToolParams({ + endpointIds, + insightType, + actionsClient, + anonymizationFields, + apiConfig, + esClient, + connectorTimeout, + langChainTimeout, + langSmithProject, + langSmithApiKey, + logger, + latestReplacements, + onNewReplacements, + request, +}: { + endpointIds: string[]; + insightType: DefendInsightType; + actionsClient: PublicMethodsOf; + anonymizationFields?: AnonymizationFieldResponse[]; + apiConfig: ApiConfig; + esClient: ElasticsearchClient; + connectorTimeout: number; + langChainTimeout: number; + langSmithProject?: string; + langSmithApiKey?: string; + logger: Logger; + latestReplacements: Replacements; + onNewReplacements: (newReplacements: Replacements) => void; + request: KibanaRequest; +}): { + endpointIds: string[]; + insightType: DefendInsightType; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + langChainTimeout: number; + llm: ActionsClientLlm; + logger: Logger; + replacements: Replacements; + onNewReplacements: (newReplacements: Replacements) => void; + request: KibanaRequest; + modelExists: boolean; + isEnabledKnowledgeBase: boolean; +} { + const traceOptions = { + projectName: langSmithProject, + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: langSmithProject, + logger, + }), + ], + }; + + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: apiConfig.connectorId, + llmType: getLlmType(apiConfig.actionTypeId), + logger, + temperature: 0, // zero temperature because we want structured JSON output + timeout: connectorTimeout, + traceOptions, + }); + + return { + endpointIds, + insightType, + anonymizationFields, + esClient, + replacements: latestReplacements, + langChainTimeout, + llm, + logger, + onNewReplacements, + request, + modelExists: false, + isEnabledKnowledgeBase: false, + }; +} + +export async function handleToolError({ + apiConfig, + defendInsightId, + authenticatedUser, + dataClient, + err, + latestReplacements, + logger, + telemetry, +}: { + apiConfig: ApiConfig; + defendInsightId: string; + authenticatedUser: AuthenticatedUser; + dataClient: DefendInsightsDataClient; + err: Error; + latestReplacements: Replacements; + logger: Logger; + telemetry: AnalyticsServiceSetup; +}) { + try { + logger.error(err); + const error = transformError(err); + const currentInsight = await dataClient.getDefendInsight({ + id: defendInsightId, + authenticatedUser, + }); + + if (currentInsight === null || currentInsight?.status === DefendInsightStatus.Enum.canceled) { + return; + } + await dataClient.updateDefendInsight({ + defendInsightUpdateProps: { + insights: [], + status: DefendInsightStatus.Enum.failed, + id: defendInsightId, + replacements: latestReplacements, + backingIndex: currentInsight.backingIndex, + failureReason: error.message, + }, + authenticatedUser, + }); + telemetry.reportEvent(DEFEND_INSIGHT_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: error.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } catch (updateErr) { + const updateError = transformError(updateErr); + telemetry.reportEvent(DEFEND_INSIGHT_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: updateError.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } +} + +export async function createDefendInsight( + endpointIds: string[], + insightType: DefendInsightType, + dataClient: DefendInsightsDataClient, + authenticatedUser: AuthenticatedUser, + apiConfig: ApiConfig +): Promise<{ + currentInsight: DefendInsightsResponse; + defendInsightId: string; +}> { + const currentInsight = await dataClient?.createDefendInsight({ + defendInsightCreate: { + endpointIds, + insightType, + apiConfig, + insights: [], + status: DefendInsightStatus.Enum.running, + }, + authenticatedUser, + }); + + if (!currentInsight) { + throw new Error(`failed to create Defend insight for connectorId: ${apiConfig.connectorId}`); + } + + return { + defendInsightId: currentInsight.id, + currentInsight, + }; +} + +export async function updateDefendInsights({ + apiConfig, + defendInsightId, + authenticatedUser, + dataClient, + latestReplacements, + logger, + rawDefendInsights, + startTime, + telemetry, +}: { + apiConfig: ApiConfig; + defendInsightId: string; + authenticatedUser: AuthenticatedUser; + dataClient: DefendInsightsDataClient; + latestReplacements: Replacements; + logger: Logger; + rawDefendInsights: string | null; + startTime: Moment; + telemetry: AnalyticsServiceSetup; +}) { + try { + if (rawDefendInsights == null) { + throw new Error('tool returned no Defend insights'); + } + const currentInsight = await dataClient.getDefendInsight({ + id: defendInsightId, + authenticatedUser, + }); + if (currentInsight === null || currentInsight?.status === DefendInsightStatus.Enum.canceled) { + return; + } + const endTime = moment(); + const durationMs = endTime.diff(startTime); + const { eventsContextCount, insights } = getDataFromJSON(rawDefendInsights); + const updateProps = { + eventsContextCount, + insights, + status: DefendInsightStatus.Enum.succeeded, + ...(!eventsContextCount || !insights.length + ? {} + : { + generationIntervals: addGenerationInterval(currentInsight.generationIntervals, { + durationMs, + date: new Date().toISOString(), + }), + }), + id: defendInsightId, + replacements: latestReplacements, + backingIndex: currentInsight.backingIndex, + }; + + await dataClient.updateDefendInsight({ + defendInsightUpdateProps: updateProps, + authenticatedUser, + }); + telemetry.reportEvent(DEFEND_INSIGHT_SUCCESS_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + eventsContextCount: updateProps.eventsContextCount, + insightsGenerated: updateProps.insights.length, + durationMs, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } catch (updateErr) { + logger.error(updateErr); + const updateError = transformError(updateErr); + telemetry.reportEvent(DEFEND_INSIGHT_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: updateError.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } +} + +export async function updateDefendInsightsLastViewedAt({ + params, + authenticatedUser, + dataClient, +}: { + params: DefendInsightsGetRequestQuery; + authenticatedUser: AuthenticatedUser; + dataClient: DefendInsightsDataClient; +}): Promise { + const defendInsights = await dataClient.findDefendInsightsByParams({ + params, + authenticatedUser, + }); + if (!defendInsights.length) { + return []; + } + + const defendInsightsUpdateProps = defendInsights.map((insight) => { + return { + id: insight.id, + lastViewedAt: new Date().toISOString(), + backingIndex: insight.backingIndex, + }; + }); + + return dataClient.updateDefendInsights({ + defendInsightsUpdateProps, + authenticatedUser, + }); +} + +export async function updateDefendInsightLastViewedAt({ + id, + authenticatedUser, + dataClient, +}: { + id: string; + authenticatedUser: AuthenticatedUser; + dataClient: DefendInsightsDataClient; +}): Promise { + return ( + await updateDefendInsightsLastViewedAt({ params: { ids: [id] }, authenticatedUser, dataClient }) + )[0]; +} diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/index.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/index.ts new file mode 100644 index 0000000000000..a2835cb74c82d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/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 { getDefendInsightRoute } from './get_defend_insight'; +export { getDefendInsightsRoute } from './get_defend_insights'; +export { postDefendInsightsRoute } from './post_defend_insights'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.test.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.test.ts new file mode 100644 index 0000000000000..95d6b521ed4b0 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.test.ts @@ -0,0 +1,184 @@ +/* + * 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 @typescript-eslint/no-explicit-any */ + +import type { AuthenticatedUser } from '@kbn/core-security-common'; +import type { DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common'; + +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; +import { DefendInsightStatus, DefendInsightType } from '@kbn/elastic-assistant-common'; + +import type { DefendInsightsDataClient } from '../../ai_assistant_data_clients/defend_insights'; + +import { serverMock } from '../../__mocks__/server'; +import { + ElasticAssistantRequestHandlerContextMock, + requestContextMock, +} from '../../__mocks__/request_context'; +import { transformESSearchToDefendInsights } from '../../ai_assistant_data_clients/defend_insights/helpers'; +import { getDefendInsightsSearchEsMock } from '../../__mocks__/defend_insights_schema.mock'; +import { postDefendInsightsRequest } from '../../__mocks__/request'; +import { getAssistantTool, createDefendInsight, isDefendInsightsEnabled } from './helpers'; +import { postDefendInsightsRoute } from './post_defend_insights'; + +jest.mock('./helpers'); + +describe('postDefendInsightsRoute', () => { + let server: ReturnType; + let context: ElasticAssistantRequestHandlerContextMock; + let mockUser: AuthenticatedUser; + let mockDataClient: DefendInsightsDataClient; + let mockApiConfig: any; + let mockRequestBody: DefendInsightsPostRequestBody; + let mockCurrentInsight: any; + + function getDefaultUser(): AuthenticatedUser { + return { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + } + + function getDefaultDataClient(): DefendInsightsDataClient { + return { + findDefendInsightsByParams: jest.fn().mockResolvedValueOnce(mockCurrentInsight), + updateDefendInsight: jest.fn(), + createDefendInsight: jest.fn(), + } as unknown as DefendInsightsDataClient; + } + + function getDefaultApiConfig() { + return { + connectorId: 'connector-id', + actionTypeId: '.bedrock', + model: 'model', + provider: OpenAiProviderType.OpenAi, + }; + } + + function getDefaultRequestBody(): DefendInsightsPostRequestBody { + return { + endpointIds: [], + insightType: DefendInsightType.Enum.incompatible_antivirus, + subAction: 'invokeAI', + apiConfig: mockApiConfig, + anonymizationFields: [], + replacements: {}, + model: 'gpt-4', + langSmithProject: 'langSmithProject', + langSmithApiKey: 'langSmithApiKey', + }; + } + + beforeEach(() => { + const tools = requestContextMock.createTools(); + context = tools.context; + server = serverMock.create(); + tools.clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient(); + + mockCurrentInsight = transformESSearchToDefendInsights(getDefendInsightsSearchEsMock())[0]; + mockCurrentInsight.status = DefendInsightStatus.Enum.running; + + mockUser = getDefaultUser(); + mockDataClient = getDefaultDataClient(); + mockApiConfig = getDefaultApiConfig(); + mockRequestBody = getDefaultRequestBody(); + (getAssistantTool as jest.Mock).mockReturnValue({ getTool: jest.fn() }); + (createDefendInsight as jest.Mock).mockResolvedValue({ + currentInsight: mockCurrentInsight, + defendInsightId: mockCurrentInsight.id, + }); + (isDefendInsightsEnabled as jest.Mock).mockResolvedValue(true); + + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser); + context.elasticAssistant.getDefendInsightsDataClient.mockResolvedValue(mockDataClient); + context.elasticAssistant.actions = actionsMock.createStart(); + + postDefendInsightsRoute(server.router); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should handle successful request', async () => { + const response = await server.inject( + postDefendInsightsRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockCurrentInsight); + }); + + it('should handle missing authenticated user', async () => { + context.elasticAssistant.getCurrentUser.mockReturnValueOnce(null); + const response = await server.inject( + postDefendInsightsRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(401); + expect(response.body).toEqual({ + message: 'Authenticated user not found', + status_code: 401, + }); + }); + + it('should handle missing data client', async () => { + context.elasticAssistant.getDefendInsightsDataClient.mockResolvedValueOnce(null); + const response = await server.inject( + postDefendInsightsRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Defend insights data client not initialized', + status_code: 500, + }); + }); + + it('should handle assistantTool null response', async () => { + (getAssistantTool as jest.Mock).mockReturnValueOnce(null); + const response = await server.inject( + postDefendInsightsRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(404); + }); + + it('should 404 if feature flag disabled', async () => { + (isDefendInsightsEnabled as jest.Mock).mockReturnValueOnce(false); + const response = await server.inject( + postDefendInsightsRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(404); + }); + + it('should handle createDefendInsight error', async () => { + (createDefendInsight as jest.Mock).mockRejectedValueOnce(new Error('Oh no!')); + const response = await server.inject( + postDefendInsightsRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: { + error: 'Oh no!', + success: false, + }, + status_code: 500, + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.ts b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.ts new file mode 100644 index 0000000000000..d69b60a478803 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/defend_insights/post_defend_insights.ts @@ -0,0 +1,196 @@ +/* + * 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 moment from 'moment/moment'; + +import type { IKibanaResponse } from '@kbn/core/server'; + +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { + DEFEND_INSIGHTS, + DefendInsightsPostRequestBody, + DefendInsightsPostResponse, + ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + Replacements, +} from '@kbn/elastic-assistant-common'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { IRouter, Logger } from '@kbn/core/server'; + +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; +import { + getAssistantTool, + getAssistantToolParams, + handleToolError, + createDefendInsight, + updateDefendInsights, + isDefendInsightsEnabled, +} from './helpers'; + +const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes +const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds +const CONNECTOR_TIMEOUT = LANG_CHAIN_TIMEOUT - 10_000; // 9 minutes 40 seconds + +export const postDefendInsightsRoute = (router: IRouter) => { + router.versioned + .post({ + access: 'internal', + path: DEFEND_INSIGHTS, + options: { + tags: ['access:elasticAssistant'], + timeout: { + idleSocket: ROUTE_HANDLER_TIMEOUT, + }, + }, + }) + .addVersion( + { + version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + validate: { + request: { + body: buildRouteValidationWithZod(DefendInsightsPostRequestBody), + }, + response: { + 200: { + body: { custom: buildRouteValidationWithZod(DefendInsightsPostResponse) }, + }, + }, + }, + }, + async (context, request, response): Promise> => { + const startTime = moment(); // start timing the generation + const resp = buildResponse(response); + const assistantContext = await context.elasticAssistant; + const logger: Logger = assistantContext.logger; + const telemetry = assistantContext.telemetry; + + try { + const isEnabled = isDefendInsightsEnabled({ + request, + logger, + assistantContext, + }); + if (!isEnabled) { + return response.notFound(); + } + + const actions = assistantContext.actions; + const actionsClient = await actions.getActionsClientWithRequest(request); + const dataClient = await assistantContext.getDefendInsightsDataClient(); + const authenticatedUser = assistantContext.getCurrentUser(); + if (authenticatedUser == null) { + return resp.error({ + body: `Authenticated user not found`, + statusCode: 401, + }); + } + if (!dataClient) { + return resp.error({ + body: `Defend insights data client not initialized`, + statusCode: 500, + }); + } + + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const assistantTool = getAssistantTool(assistantContext.getRegisteredTools, pluginName); + + if (!assistantTool) { + return response.notFound(); + } + + const { + endpointIds, + insightType, + apiConfig, + anonymizationFields, + langSmithApiKey, + langSmithProject, + replacements, + } = request.body; + + const esClient = (await context.core).elasticsearch.client.asCurrentUser; + + let latestReplacements: Replacements = { ...replacements }; + const onNewReplacements = (newReplacements: Replacements) => { + latestReplacements = { ...latestReplacements, ...newReplacements }; + }; + + const assistantToolParams = getAssistantToolParams({ + endpointIds, + insightType, + actionsClient, + anonymizationFields, + apiConfig, + esClient, + latestReplacements, + connectorTimeout: CONNECTOR_TIMEOUT, + langChainTimeout: LANG_CHAIN_TIMEOUT, + langSmithProject, + langSmithApiKey, + logger, + onNewReplacements, + request, + }); + + const toolInstance = assistantTool.getTool(assistantToolParams); + + const { currentInsight, defendInsightId } = await createDefendInsight( + endpointIds, + insightType, + dataClient, + authenticatedUser, + apiConfig + ); + + toolInstance + ?.invoke('') + .then((rawDefendInsights: string) => + updateDefendInsights({ + apiConfig, + defendInsightId, + authenticatedUser, + dataClient, + latestReplacements, + logger, + rawDefendInsights, + startTime, + telemetry, + }) + ) + .catch((err) => + handleToolError({ + apiConfig, + defendInsightId, + authenticatedUser, + dataClient, + err, + latestReplacements, + logger, + telemetry, + }) + ); + + return response.ok({ + body: currentInsight, + }); + } catch (err) { + logger.error(err); + const error = transformError(err); + + return resp.error({ + body: { success: false, error: error.message }, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts index fcd051f1f2157..23ec7011be5b7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts @@ -20,6 +20,7 @@ import { Message, Replacements, replaceAnonymizedValuesWithOriginalValues, + DEFEND_INSIGHTS_TOOL_ID, } from '@kbn/elastic-assistant-common'; import { ILicense } from '@kbn/licensing-plugin/server'; import { i18n } from '@kbn/i18n'; @@ -263,9 +264,11 @@ export const langChainExecute = async ({ logger, }); const assistantContext = context.elasticAssistant; + // We don't (yet) support invoking these tools interactively + const unsupportedTools = new Set(['attack-discovery', DEFEND_INSIGHTS_TOOL_ID]); const assistantTools = assistantContext .getRegisteredTools(pluginName) - .filter((x) => x.id !== 'attack-discovery'); // We don't (yet) support asking the assistant for NEW attack discoveries from a conversation + .filter((tool) => !unsupportedTools.has(tool.id)); // get a scoped esClient for assistant memory const esClient = context.core.elasticsearch.client.asCurrentUser; diff --git a/x-pack/plugins/elastic_assistant/server/routes/index.ts b/x-pack/plugins/elastic_assistant/server/routes/index.ts index c30a62872a82d..ada5bf1c600d6 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/index.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/index.ts @@ -12,6 +12,11 @@ export { postActionsConnectorExecuteRoute } from './post_actions_connector_execu export { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; export { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; +// Defend insights +export { postDefendInsightsRoute } from './defend_insights/post_defend_insights'; +export { getDefendInsightsRoute } from './defend_insights/get_defend_insights'; +export { getDefendInsightRoute } from './defend_insights/get_defend_insight'; + // Knowledge Base export { getKnowledgeBaseIndicesRoute } from './knowledge_base/get_knowledge_base_indices'; export { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base_status'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index d722e31cb2338..0124dfc7969c2 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -33,6 +33,11 @@ import { postActionsConnectorExecuteRoute } from './post_actions_connector_execu import { bulkActionKnowledgeBaseEntriesRoute } from './knowledge_base/entries/bulk_actions_route'; import { createKnowledgeBaseEntryRoute } from './knowledge_base/entries/create_route'; import { findKnowledgeBaseEntriesRoute } from './knowledge_base/entries/find_route'; +import { + getDefendInsightRoute, + getDefendInsightsRoute, + postDefendInsightsRoute, +} from './defend_insights'; export const registerRoutes = ( router: ElasticAssistantPluginRouter, @@ -89,4 +94,9 @@ export const registerRoutes = ( getAttackDiscoveryRoute(router); postAttackDiscoveryRoute(router); cancelAttackDiscoveryRoute(router); + + // Defend insights + getDefendInsightRoute(router); + getDefendInsightsRoute(router); + postDefendInsightsRoute(router); }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts index 3f81763db49d9..ef921d7c91a28 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts @@ -116,6 +116,16 @@ export class RequestContextFactory implements IRequestContextFactory { }); }), + getDefendInsightsDataClient: memoize(() => { + const currentUser = getCurrentUser(); + return this.assistantService.createDefendInsightsDataClient({ + spaceId: getSpaceId(), + licensing: context.licensing, + logger: this.logger, + currentUser, + }); + }), + getAIAssistantPromptsDataClient: memoize(() => { const currentUser = getCurrentUser(); return this.assistantService.createAIAssistantPromptsDataClient({ diff --git a/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts b/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts index e91a0ec024c9e..061e4e6f47af5 100644 --- a/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts +++ b/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts @@ -54,6 +54,7 @@ describe('AppContextService', () => { appContextService.start(mockAppContext); appContextService.registerFeatures('super', { assistantModelEvaluation: true, + defendInsights: true, }); appContextService.stop(); @@ -104,6 +105,7 @@ describe('AppContextService', () => { const features: AssistantFeatures = { ...defaultAssistantFeatures, assistantModelEvaluation: true, + defendInsights: true, }; appContextService.start(mockAppContext); @@ -119,11 +121,13 @@ describe('AppContextService', () => { const featuresOne: AssistantFeatures = { ...defaultAssistantFeatures, assistantModelEvaluation: true, + defendInsights: true, }; const pluginTwo = 'plugin2'; const featuresTwo: AssistantFeatures = { ...defaultAssistantFeatures, assistantModelEvaluation: false, + defendInsights: false, }; appContextService.start(mockAppContext); @@ -139,10 +143,12 @@ describe('AppContextService', () => { const featuresOne: AssistantFeatures = { ...defaultAssistantFeatures, assistantModelEvaluation: true, + defendInsights: true, }; const featuresTwo: AssistantFeatures = { ...defaultAssistantFeatures, assistantModelEvaluation: false, + defendInsights: false, }; appContextService.start(mockAppContext); @@ -164,6 +170,7 @@ describe('AppContextService', () => { const pluginName = 'pluginName'; const featuresSubset: Partial = { assistantModelEvaluation: true, + defendInsights: true, }; appContextService.start(mockAppContext); diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index d2dad4f9f998f..d328001e86bb8 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -27,6 +27,7 @@ import { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import { ElasticsearchClient } from '@kbn/core/server'; import { AttackDiscoveryPostRequestBody, + DefendInsightsPostRequestBody, AssistantFeatures, ExecuteConnectorRequestBody, Replacements, @@ -51,6 +52,7 @@ import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/ import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; import { AIAssistantDataClient } from './ai_assistant_data_clients'; import { AIAssistantKnowledgeBaseDataClient } from './ai_assistant_data_clients/knowledge_base'; +import type { DefendInsightsDataClient } from './ai_assistant_data_clients/defend_insights'; export const PLUGIN_ID = 'elasticAssistant' as const; @@ -129,6 +131,7 @@ export interface ElasticAssistantApiRequestHandlerContext { params?: GetAIAssistantKnowledgeBaseDataClientParams ) => Promise; getAttackDiscoveryDataClient: () => Promise; + getDefendInsightsDataClient: () => Promise; getAIAssistantPromptsDataClient: () => Promise; getAIAssistantAnonymizationFieldsDataClient: () => Promise; inference: InferenceServerStart; @@ -158,6 +161,7 @@ export interface AssistantResourceNames { prompts: string; anonymizationFields: string; attackDiscovery: string; + defendInsights: string; }; indexTemplate: { conversations: string; @@ -165,6 +169,7 @@ export interface AssistantResourceNames { prompts: string; anonymizationFields: string; attackDiscovery: string; + defendInsights: string; }; aliases: { conversations: string; @@ -172,6 +177,7 @@ export interface AssistantResourceNames { prompts: string; anonymizationFields: string; attackDiscovery: string; + defendInsights: string; }; indexPatterns: { conversations: string; @@ -179,6 +185,7 @@ export interface AssistantResourceNames { prompts: string; anonymizationFields: string; attackDiscovery: string; + defendInsights: string; }; pipelines: { knowledgeBase: string; @@ -230,7 +237,7 @@ export interface AssistantToolParams { request: KibanaRequest< unknown, unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody | DefendInsightsPostRequestBody >; size?: number; telemetry?: AnalyticsServiceSetup; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 0ab749735d06c..b53c7ae761547 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -18,6 +18,7 @@ export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTION_RESPONSES_DS}- export const ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN = `${ENDPOINT_ACTION_RESPONSES_DS}-*`; export const eventsIndexPattern = 'logs-endpoint.events.*'; +export const FILE_EVENTS_INDEX_PATTERN = 'logs-endpoint.events.file-*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; // metadata datastream diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index dc6495e1d9737..7fcdabad3b36c 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -236,6 +236,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the siem migrations feature */ siemMigrationsEnabled: false, + + /** + * Enables the Defend Insights feature + */ + defendInsights: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/errors.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/errors.ts new file mode 100644 index 0000000000000..03633d2ae1eed --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/errors.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. + */ + +import { EndpointError } from '../../../../common/endpoint/errors'; + +export class InvalidDefendInsightTypeError extends EndpointError { + constructor() { + super('invalid defend insight type'); + } +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/get_file_events_query.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/get_file_events_query.ts new file mode 100644 index 0000000000000..fa8f6fa1e33b4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/get_file_events_query.ts @@ -0,0 +1,49 @@ +/* + * 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 { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; + +import { FILE_EVENTS_INDEX_PATTERN } from '../../../../../common/endpoint/constants'; + +const SIZE = 200; + +export function getFileEventsQuery({ endpointIds }: { endpointIds: string[] }): SearchRequest { + return { + allow_no_indices: true, + fields: ['_id', 'agent.id', 'process.executable'], + query: { + bool: { + must: [ + { + terms: { + 'agent.id': endpointIds, + }, + }, + { + range: { + '@timestamp': { + gte: 'now-24h', + lte: 'now', + }, + }, + }, + ], + }, + }, + size: SIZE, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + _source: false, + ignore_unavailable: true, + index: [FILE_EVENTS_INDEX_PATTERN], + }; +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.test.ts new file mode 100644 index 0000000000000..7c2fd9f61e255 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.test.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 type { ElasticsearchClient } from '@kbn/core/server'; + +import { DefendInsightType, transformRawData } from '@kbn/elastic-assistant-common'; + +import { InvalidDefendInsightTypeError } from '../errors'; +import { getFileEventsQuery } from './get_file_events_query'; +import { getAnonymizedEvents } from '.'; + +jest.mock('@kbn/elastic-assistant-common', () => { + const originalModule = jest.requireActual('@kbn/elastic-assistant-common'); + return { + ...originalModule, + transformRawData: jest.fn(), + }; +}); + +jest.mock('./get_file_events_query', () => ({ + getFileEventsQuery: jest.fn(), +})); + +describe('getAnonymizedEvents', () => { + let mockEsClient: jest.Mocked; + + const mockHits = [ + { _index: 'test-index', fields: { field1: ['value1'] } }, + { _index: 'test-index', fields: { field2: ['value2'] } }, + ]; + + beforeEach(() => { + (getFileEventsQuery as jest.Mock).mockReturnValue({ index: 'test-index', body: {} }); + (transformRawData as jest.Mock).mockImplementation( + ({ rawData }) => `anonymized_${Object.values(rawData)[0]}` + ); + mockEsClient = { + search: jest.fn().mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + hits: mockHits, + }, + }), + } as unknown as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return anonymized events successfully', async () => { + const result = await getAnonymizedEvents({ + endpointIds: ['endpoint1'], + type: DefendInsightType.Enum.incompatible_antivirus, + esClient: mockEsClient, + }); + + expect(result).toEqual(['anonymized_value1', 'anonymized_value2']); + expect(getFileEventsQuery).toHaveBeenCalledWith({ endpointIds: ['endpoint1'] }); + expect(mockEsClient.search).toHaveBeenCalledWith({ index: 'test-index', body: {} }); + expect(transformRawData).toHaveBeenCalledTimes(2); + }); + + it('should throw InvalidDefendInsightTypeError for invalid type', async () => { + await expect( + getAnonymizedEvents({ + endpointIds: ['endpoint1'], + type: 'invalid_type' as DefendInsightType, + esClient: mockEsClient, + }) + ).rejects.toThrow(InvalidDefendInsightTypeError); + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.ts new file mode 100644 index 0000000000000..4d9fcaf89a34a --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/get_events/index.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 type { SearchRequest, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; + +import { + getAnonymizedValue, + transformRawData, + DefendInsightType, + getRawDataOrDefault, +} from '@kbn/elastic-assistant-common'; + +import { getFileEventsQuery } from './get_file_events_query'; +import { InvalidDefendInsightTypeError } from '../errors'; + +export async function getAnonymizedEvents({ + endpointIds, + type, + anonymizationFields, + esClient, + onNewReplacements, + replacements, +}: { + endpointIds: string[]; + type: DefendInsightType; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; +}): Promise { + const query = getQuery(type, { endpointIds }); + + return getAnonymized({ + query, + anonymizationFields, + esClient, + onNewReplacements, + replacements, + }); +} + +function getQuery(type: DefendInsightType, options: { endpointIds: string[] }): SearchRequest { + if (type === DefendInsightType.Enum.incompatible_antivirus) { + const { endpointIds } = options; + return getFileEventsQuery({ + endpointIds, + }); + } + + throw new InvalidDefendInsightTypeError(); +} + +const getAnonymized = async ({ + query, + anonymizationFields, + esClient, + onNewReplacements, + replacements, +}: { + query: SearchRequest; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; +}): Promise => { + const result = await esClient.search(query); + + // Accumulate replacements locally so we can, for example use the same + // replacement for a hostname when we see it in multiple alerts: + let localReplacements = { ...(replacements ?? {}) }; + const localOnNewReplacements = (newReplacements: Replacements) => { + localReplacements = { ...localReplacements, ...newReplacements }; + + onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements + }; + + return result.hits?.hits?.map((hit) => + transformRawData({ + anonymizationFields, + currentReplacements: localReplacements, // <-- the latest local replacements + getAnonymizedValue, + onNewReplacements: localOnNewReplacements, // <-- the local callback + rawData: getRawDataOrDefault(hit.fields), + }) + ); +}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.test.ts new file mode 100644 index 0000000000000..5ef5aaeedf364 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { DynamicTool } from '@langchain/core/tools'; + +import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; +import { DEFEND_INSIGHTS_TOOL_ID, DefendInsightType } from '@kbn/elastic-assistant-common'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; + +import type { DefendInsightsToolParams } from '.'; + +import { APP_UI_ID } from '../../../../common'; +import { DEFEND_INSIGHTS_TOOL, DEFEND_INSIGHTS_TOOL_DESCRIPTION } from '.'; + +jest.mock('@kbn/elastic-assistant-plugin/server/lib/langchain/helpers', () => ({ + requestHasRequiredAnonymizationParams: jest.fn(), +})); + +describe('DEFEND_INSIGHTS_TOOL', () => { + const mockLLM = {}; + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + const mockRequest = {}; + const mockParams: DefendInsightsToolParams = { + endpointIds: ['endpoint1'], + insightType: DefendInsightType.Enum.incompatible_antivirus, + anonymizationFields: [], + esClient: mockEsClient, + langChainTimeout: 1000, + llm: mockLLM, + onNewReplacements: jest.fn(), + replacements: {}, + request: mockRequest, + } as unknown as DefendInsightsToolParams; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should have correct properties', () => { + expect(DEFEND_INSIGHTS_TOOL.id).toBe(DEFEND_INSIGHTS_TOOL_ID); + expect(DEFEND_INSIGHTS_TOOL.name).toBe('defendInsightsTool'); + expect(DEFEND_INSIGHTS_TOOL.description).toBe(DEFEND_INSIGHTS_TOOL_DESCRIPTION); + expect(DEFEND_INSIGHTS_TOOL.sourceRegister).toBe(APP_UI_ID); + }); + + it('should return tool if supported', () => { + (requestHasRequiredAnonymizationParams as jest.Mock).mockReturnValue(true); + const tool = DEFEND_INSIGHTS_TOOL.getTool(mockParams); + expect(tool).toBeInstanceOf(DynamicTool); + }); + + it('should return null if not request missing anonymization params', () => { + (requestHasRequiredAnonymizationParams as jest.Mock).mockReturnValue(false); + const tool = DEFEND_INSIGHTS_TOOL.getTool(mockParams); + expect(tool).toBeNull(); + }); + + it('should return null if LLM is not provided', () => { + (requestHasRequiredAnonymizationParams as jest.Mock).mockReturnValue(true); + const paramsWithoutLLM = { ...mockParams, llm: undefined }; + const tool = DEFEND_INSIGHTS_TOOL.getTool(paramsWithoutLLM) as DynamicTool; + + expect(tool).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.ts new file mode 100644 index 0000000000000..1ea26b88a15cf --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/index.ts @@ -0,0 +1,114 @@ +/* + * 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 { PromptTemplate } from '@langchain/core/prompts'; +import { DynamicTool } from '@langchain/core/tools'; +import { LLMChain } from 'langchain/chains'; +import { OutputFixingParser } from 'langchain/output_parsers'; + +import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; +import type { DefendInsightType } from '@kbn/elastic-assistant-common'; + +import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; +import { DEFEND_INSIGHTS_TOOL_ID } from '@kbn/elastic-assistant-common'; + +import { APP_UI_ID } from '../../../../common'; +import { getAnonymizedEvents } from './get_events'; +import { getDefendInsightsOutputParser } from './output_parsers'; +import { getDefendInsightsPrompt } from './prompts'; + +export const DEFEND_INSIGHTS_TOOL_DESCRIPTION = 'Call this for Elastic Defend insights.'; + +export interface DefendInsightsToolParams extends AssistantToolParams { + endpointIds: string[]; + insightType: DefendInsightType; +} + +/** + * Returns a tool for generating Elastic Defend configuration insights + */ +export const DEFEND_INSIGHTS_TOOL: AssistantTool = Object.freeze({ + id: DEFEND_INSIGHTS_TOOL_ID, + name: 'defendInsightsTool', + description: DEFEND_INSIGHTS_TOOL_DESCRIPTION, + sourceRegister: APP_UI_ID, + + isSupported: (params: AssistantToolParams): boolean => { + const { llm, request } = params; + + return requestHasRequiredAnonymizationParams(request) && llm != null; + }, + + getTool(params: AssistantToolParams): DynamicTool | null { + if (!this.isSupported(params)) return null; + + const { + endpointIds, + insightType, + anonymizationFields, + esClient, + langChainTimeout, + llm, + onNewReplacements, + replacements, + } = params as DefendInsightsToolParams; + + return new DynamicTool({ + name: 'DefendInsightsTool', + description: DEFEND_INSIGHTS_TOOL_DESCRIPTION, + func: async () => { + if (llm == null) { + throw new Error('LLM is required for Defend Insights'); + } + + const anonymizedEvents = await getAnonymizedEvents({ + endpointIds, + type: insightType, + anonymizationFields, + esClient, + onNewReplacements, + replacements, + }); + + const eventsContextCount = anonymizedEvents.length; + if (eventsContextCount === 0) { + return JSON.stringify({ eventsContextCount, insights: [] }, null, 2); + } + + const outputParser = getDefendInsightsOutputParser({ type: insightType }); + const outputFixingParser = OutputFixingParser.fromLLM(llm, outputParser); + + const prompt = new PromptTemplate({ + template: `Answer the user's question as best you can:\n{format_instructions}\n{query}`, + inputVariables: ['query'], + partialVariables: { + format_instructions: outputFixingParser.getFormatInstructions(), + }, + }); + + const answerFormattingChain = new LLMChain({ + llm, + prompt, + outputKey: 'records', + outputParser: outputFixingParser, + }); + + const result = await answerFormattingChain.call({ + query: getDefendInsightsPrompt({ + type: insightType, + events: anonymizedEvents, + }), + timeout: langChainTimeout, + }); + const insights = result.records; + + return JSON.stringify({ eventsContextCount, insights }, null, 2); + }, + tags: [DEFEND_INSIGHTS_TOOL_ID], + }); + }, +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/incompatible_antivirus.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/incompatible_antivirus.ts new file mode 100644 index 0000000000000..b6430e4408355 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/incompatible_antivirus.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 { StructuredOutputParser } from 'langchain/output_parsers'; + +import { z } from '@kbn/zod'; + +export function getIncompatibleVirusOutputParser() { + return StructuredOutputParser.fromZodSchema( + z.array( + z.object({ + group: z.string().describe('The program which is triggering the events'), + events: z + .object({ + id: z.string().describe('The event ID'), + endpointId: z.string().describe('The endpoint ID'), + value: z.string().describe('The process.executable value of the event'), + }) + .array() + .describe('The events that the insight is based on'), + }) + ) + ); +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/index.ts new file mode 100644 index 0000000000000..78933b72702bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/output_parsers/index.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 { DefendInsightType } from '@kbn/elastic-assistant-common'; + +import { InvalidDefendInsightTypeError } from '../errors'; +import { getIncompatibleVirusOutputParser } from './incompatible_antivirus'; + +export function getDefendInsightsOutputParser({ type }: { type: DefendInsightType }) { + if (type === DefendInsightType.Enum.incompatible_antivirus) { + return getIncompatibleVirusOutputParser(); + } + + throw new InvalidDefendInsightTypeError(); +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/incompatible_antivirus.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/incompatible_antivirus.ts new file mode 100644 index 0000000000000..516de86a30975 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/incompatible_antivirus.ts @@ -0,0 +1,16 @@ +/* + * 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 getIncompatibleAntivirusPrompt({ events }: { events: string[] }): string { + return `You are an Elastic Security user tasked with analyzing file events from Elastic Security to identify antivirus processes. Only focus on detecting antivirus processes. Ignore processes that belong to Elastic Agent or Elastic Defend, that are not antivirus processes, or are typical processes built into the operating system. Accuracy is of the utmost importance, try to minimize false positives. Group the processes by the antivirus program, keeping track of the agent.id and _id associated to each of the individual events as endpointId and eventId respectively. If there are no events, ignore the group field. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. + + Use context from the following process events to provide insights: + """ + ${events.join('\n\n')} + """ + `; +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/index.ts new file mode 100644 index 0000000000000..d58778c3c544b --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/defend_insights/prompts/index.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 { DefendInsightType } from '@kbn/elastic-assistant-common'; + +import { InvalidDefendInsightTypeError } from '../errors'; +import { getIncompatibleAntivirusPrompt } from './incompatible_antivirus'; + +export function getDefendInsightsPrompt({ + type, + events, +}: { + type: DefendInsightType; + events: string[]; +}): string { + if (type === DefendInsightType.Enum.incompatible_antivirus) { + return getIncompatibleAntivirusPrompt({ events }); + } + + throw new InvalidDefendInsightTypeError(); +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/index.ts index 9bb85f5beedae..f7824e688afe2 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/index.ts @@ -10,12 +10,14 @@ import type { AssistantTool } from '@kbn/elastic-assistant-plugin/server'; import { NL_TO_ESQL_TOOL } from './esql/nl_to_esql_tool'; import { ALERT_COUNTS_TOOL } from './alert_counts/alert_counts_tool'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool'; +import { DEFEND_INSIGHTS_TOOL } from './defend_insights'; import { KNOWLEDGE_BASE_RETRIEVAL_TOOL } from './knowledge_base/knowledge_base_retrieval_tool'; import { KNOWLEDGE_BASE_WRITE_TOOL } from './knowledge_base/knowledge_base_write_tool'; import { SECURITY_LABS_KNOWLEDGE_BASE_TOOL } from './security_labs/security_labs_tool'; export const assistantTools: AssistantTool[] = [ ALERT_COUNTS_TOOL, + DEFEND_INSIGHTS_TOOL, NL_TO_ESQL_TOOL, KNOWLEDGE_BASE_RETRIEVAL_TOOL, KNOWLEDGE_BASE_WRITE_TOOL, From d78b26542343f4f62bde6d32aa6f2bda097dc977 Mon Sep 17 00:00:00 2001 From: Elena Shostak <165678770+elena-shostak@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:32:19 +0100 Subject: [PATCH 06/61] [FTR] Cleaned up removed apm_user role (#200680) ## Summary Cleaned up check for removed `apm_user` role, see https://github.com/elastic/elasticsearch/pull/116712 ### 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 - [x] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) __Closes: https://github.com/elastic/kibana/issues/200667__ --- x-pack/test/accessibility/apps/group1/users.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/accessibility/apps/group1/users.ts b/x-pack/test/accessibility/apps/group1/users.ts index e26e6a6f6a54f..138f0995cbaae 100644 --- a/x-pack/test/accessibility/apps/group1/users.ts +++ b/x-pack/test/accessibility/apps/group1/users.ts @@ -62,7 +62,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { confirm_password: 'password', full_name: 'a11y user', email: 'example@example.com', - roles: ['apm_user'], + roles: ['editor'], }); await testSubjects.click('rolesDropdown'); await a11y.testAppSnapshot(); @@ -75,7 +75,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { confirm_password: 'password', full_name: 'DeleteA11y user', email: 'example@example.com', - roles: ['apm_user'], + roles: ['editor'], }); await testSubjects.click('checkboxSelectRow-deleteA11y'); await a11y.testAppSnapshot(); From f61c043bf6b00c26a2226537d6d7ad14a1dbc1c9 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 19 Nov 2024 11:42:28 +0100 Subject: [PATCH 07/61] [Discover][Field caps] Align with the ES responses for closed indices (#199717) - Closes: https://github.com/elastic/kibana/issues/199413 - Related: https://github.com/elastic/kibana/pull/199654 - Related ES PR: https://github.com/elastic/elasticsearch/pull/116021 - Related ES PR: https://github.com/elastic/elasticsearch/pull/116656 ## Summary This PR unskips tests and updates the Kibana API to the updated ES responses. ### 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 --- .../data_views/server/fetcher/lib/es_api.ts | 5 ++- .../fields_for_wildcard_route/response.ts | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/plugins/data_views/server/fetcher/lib/es_api.ts b/src/plugins/data_views/server/fetcher/lib/es_api.ts index c5a85267c0e55..c7c3a8c6736bd 100644 --- a/src/plugins/data_views/server/fetcher/lib/es_api.ts +++ b/src/plugins/data_views/server/fetcher/lib/es_api.ts @@ -94,7 +94,10 @@ export async function callFieldCapsApi(params: FieldCapsApiParams) { ); } catch (error) { // return an empty set for closed indices - if (error.message.startsWith('cluster_block_exception')) { + if ( + error.message.startsWith('index_closed_exception') || + error.message.startsWith('cluster_block_exception') + ) { return { body: { indices: [], fields: {} } }; } throw convertEsError(indices, error); diff --git a/test/api_integration/apis/data_views/fields_for_wildcard_route/response.ts b/test/api_integration/apis/data_views/fields_for_wildcard_route/response.ts index a80ca89ee4865..1810129552215 100644 --- a/test/api_integration/apis/data_views/fields_for_wildcard_route/response.ts +++ b/test/api_integration/apis/data_views/fields_for_wildcard_route/response.ts @@ -21,6 +21,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); const esClient = getService('es'); + const log = getService('log'); const ensureFieldsAreSorted = (resp: { body: { fields: { name: string } } }) => { expect(resp.body.fields).to.eql(sortBy(resp.body.fields, 'name')); @@ -80,8 +81,7 @@ export default function ({ getService }: FtrProviderContext) { }, ]; - // Failing: See https://github.com/elastic/kibana/issues/199413 - describe.skip('fields_for_wildcard_route response', () => { + describe('fields_for_wildcard_route response', () => { before(() => esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index') ); @@ -240,7 +240,7 @@ export default function ({ getService }: FtrProviderContext) { .expect(404); }); - it('returns 200 when index is closed', async () => { + it('returns 200 when index is closed and allow_no_index is true', async () => { const es = getService('es'); await es.indices.close({ index: 'basic_index' }); @@ -249,13 +249,36 @@ export default function ({ getService }: FtrProviderContext) { .get(FIELDS_FOR_WILDCARD_PATH) .set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION_INTERNAL) .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') - .query({ pattern: 'basic_index' }) + .query({ pattern: 'basic_index', allow_no_index: true }) + .expect((response) => { + if (response.statusCode !== 200) { + log.debug(response.body); + } + }) .expect(200, { fields: [], indices: [], }); }); + it('returns 404 when index is closed and allow_no_index is false', async () => { + const es = getService('es'); + + await es.indices.close({ index: 'basic_index' }); + + await supertest + .get(FIELDS_FOR_WILDCARD_PATH) + .set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION_INTERNAL) + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .query({ pattern: 'basic_index' }) + .expect((response) => { + if (response.statusCode !== 404) { + log.debug(response.body); + } + }) + .expect(404); + }); + it('returns empty set when no fields even if meta fields are supplied', async () => { await esClient.indices.create({ index: 'fields-for-wildcard-000001' }); From d900299e731fccaca5c70889371a2760da794da1 Mon Sep 17 00:00:00 2001 From: Jesus Wahrman <41008968+jesuswr@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:46:18 +0100 Subject: [PATCH 08/61] Clean code related to savedObjectClientContrat that wasn't being used (#200629) ## Summary Resolves: https://github.com/elastic/kibana/issues/197216 Removed all code that wasn't being used in https://github.com/elastic/kibana/tree/main/src/plugins/saved_objects/public. Didn't remove the actual client since it's still being used here: https://github.com/elastic/kibana/blob/dbab2214e9451d3a262007660cc0b7cdcea5307c/src/plugins/home/public/application/kibana_services.ts#L46-L48 Checked everything with `node scripts/type_check` ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/saved_objects/public/index.ts | 2 +- .../saved_object/helpers/apply_es_resp.ts | 101 --------------- .../helpers/check_for_duplicate_title.ts | 65 ---------- .../helpers/confirm_modal_promise.tsx | 51 -------- .../saved_object/helpers/create_source.ts | 74 ----------- .../display_duplicate_title_confirm_modal.ts | 38 ------ .../helpers/field_mapping/index.ts | 11 -- .../field_mapping/mapping_setup.test.ts | 45 ------- .../helpers/field_mapping/mapping_setup.ts | 35 ------ .../helpers/field_mapping/types.ts | 20 --- .../helpers/find_object_by_title.test.ts | 38 ------ .../helpers/find_object_by_title.ts | 45 ------- .../helpers/hydrate_index_pattern.ts | 40 ------ .../helpers/initialize_saved_object.ts | 50 -------- .../saved_object/helpers/save_saved_object.ts | 107 +--------------- .../helpers/save_with_confirmation.test.ts | 118 ------------------ .../helpers/save_with_confirmation.ts | 79 ------------ .../helpers/serialize_saved_object.ts | 55 -------- .../public/saved_object/index.ts | 2 - src/plugins/saved_objects/public/types.ts | 20 +-- src/plugins/saved_objects/tsconfig.json | 1 - .../translations/translations/fr-FR.json | 8 +- .../translations/translations/ja-JP.json | 8 +- .../translations/translations/zh-CN.json | 4 +- 24 files changed, 7 insertions(+), 1010 deletions(-) delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/check_for_duplicate_title.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/confirm_modal_promise.tsx delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/create_source.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/display_duplicate_title_confirm_modal.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/field_mapping/index.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.test.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/field_mapping/types.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.test.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/hydrate_index_pattern.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts delete mode 100644 src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index 6d7a013cf59ca..c0c8ae33f6b92 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -11,7 +11,7 @@ import { SavedObjectsPublicPlugin } from './plugin'; export type { OnSaveProps, OriginSaveModalProps, SaveModalState, SaveResult } from './save_modal'; export { SavedObjectSaveModal, SavedObjectSaveModalOrigin, showSaveModal } from './save_modal'; -export { checkForDuplicateTitle, saveWithConfirmation, isErrorNonFatal } from './saved_object'; +export { isErrorNonFatal } from './saved_object'; export type { SavedObjectSaveOpts, SavedObject, SavedObjectConfig } from './types'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts b/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts deleted file mode 100644 index 1bd1a7de29ec8..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts +++ /dev/null @@ -1,101 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { cloneDeep, defaults, forOwn, assign } from 'lodash'; -import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; -import { injectSearchSourceReferences, parseSearchSourceJSON } from '@kbn/data-plugin/public'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { EsResponse, SavedObject, SavedObjectConfig, SavedObjectKibanaServices } from '../../types'; -import { expandShorthand } from './field_mapping'; - -/** - * A given response of and ElasticSearch containing a plain saved object is applied to the given - * savedObject - */ -export async function applyESResp( - resp: EsResponse, - savedObject: SavedObject, - config: SavedObjectConfig, - dependencies: SavedObjectKibanaServices -) { - const mapping = expandShorthand(config.mapping ?? {}); - const savedObjectType = config.type || ''; - savedObject._source = cloneDeep(resp._source); - if (typeof resp.found === 'boolean' && !resp.found) { - throw new SavedObjectNotFound(savedObjectType, savedObject.id || ''); - } - - const meta = resp._source.kibanaSavedObjectMeta || {}; - delete resp._source.kibanaSavedObjectMeta; - - if (!config.indexPattern && savedObject._source.indexPattern) { - config.indexPattern = savedObject._source.indexPattern as DataView; - delete savedObject._source.indexPattern; - } - - // assign the defaults to the response - defaults(savedObject._source, savedObject.defaults); - - // transform the source using _deserializers - forOwn(mapping, (fieldMapping, fieldName) => { - if (fieldMapping._deserialize && typeof fieldName === 'string') { - savedObject._source[fieldName] = fieldMapping._deserialize( - savedObject._source[fieldName] as string - ); - } - }); - - // Give obj all of the values in _source.fields - assign(savedObject, savedObject._source); - savedObject.lastSavedTitle = savedObject.title; - - if (meta.searchSourceJSON) { - try { - let searchSourceValues = parseSearchSourceJSON(meta.searchSourceJSON); - - if (config.searchSource) { - searchSourceValues = injectSearchSourceReferences( - searchSourceValues as any, - resp.references - ); - savedObject.searchSource = await dependencies.search.searchSource.create( - searchSourceValues - ); - } else { - savedObject.searchSourceFields = searchSourceValues; - } - } catch (error) { - if ( - error.constructor.name === 'SavedObjectNotFound' && - error.savedObjectType === 'index-pattern' - ) { - // if parsing the search source fails because the index pattern wasn't found, - // remember the reference - this is required for error handling on legacy imports - savedObject.unresolvedIndexPatternReference = { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - id: JSON.parse(meta.searchSourceJSON).index, - type: 'index-pattern', - }; - } - - throw error; - } - } - - const injectReferences = config.injectReferences; - if (injectReferences && resp.references && resp.references.length > 0) { - injectReferences(savedObject, resp.references); - } - - if (typeof config.afterESResp === 'function') { - savedObject = await config.afterESResp(savedObject); - } - - return savedObject; -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/check_for_duplicate_title.ts b/src/plugins/saved_objects/public/saved_object/helpers/check_for_duplicate_title.ts deleted file mode 100644 index 78f7a5cb31d8e..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/check_for_duplicate_title.ts +++ /dev/null @@ -1,65 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { SavedObject, SavedObjectKibanaServices, StartServices } from '../../types'; -import { findObjectByTitle } from './find_object_by_title'; -import { SAVE_DUPLICATE_REJECTED } from '../../constants'; -import { displayDuplicateTitleConfirmModal } from './display_duplicate_title_confirm_modal'; - -/** - * check for an existing SavedObject with the same title in ES - * returns Promise when it's no duplicate, or the modal displaying the warning - * that's there's a duplicate is confirmed, else it returns a rejected Promise - * @param savedObject - * @param isTitleDuplicateConfirmed - * @param onTitleDuplicate - * @param services - * @param startServices - */ -export async function checkForDuplicateTitle( - savedObject: Pick< - SavedObject, - 'id' | 'title' | 'getDisplayName' | 'lastSavedTitle' | 'copyOnSave' | 'getEsType' - >, - isTitleDuplicateConfirmed: boolean, - onTitleDuplicate: (() => void) | undefined, - services: Pick, - startServices: StartServices -): Promise { - const { savedObjectsClient, overlays } = services; - // Don't check for duplicates if user has already confirmed save with duplicate title - if (isTitleDuplicateConfirmed) { - return true; - } - - // Don't check if the user isn't updating the title, otherwise that would become very annoying to have - // to confirm the save every time, except when copyOnSave is true, then we do want to check. - if (savedObject.title === savedObject.lastSavedTitle && !savedObject.copyOnSave) { - return true; - } - - const duplicate = await findObjectByTitle( - savedObjectsClient, - savedObject.getEsType(), - savedObject.title - ); - - if (!duplicate || duplicate.id === savedObject.id) { - return true; - } - - if (onTitleDuplicate) { - onTitleDuplicate(); - return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)); - } - - // TODO: make onTitleDuplicate a required prop and remove UI components from this class - // Need to leave here until all users pass onTitleDuplicate. - return displayDuplicateTitleConfirmModal(savedObject, overlays, startServices); -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/confirm_modal_promise.tsx b/src/plugins/saved_objects/public/saved_object/helpers/confirm_modal_promise.tsx deleted file mode 100644 index afdafaa5483c3..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/confirm_modal_promise.tsx +++ /dev/null @@ -1,51 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { CoreStart, OverlayStart } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal } from '@elastic/eui'; -import { toMountPoint } from '@kbn/react-kibana-mount'; - -type StartServices = Pick; - -export function confirmModalPromise( - message = '', - title = '', - confirmBtnText = '', - overlays: OverlayStart, - startServices: StartServices -): Promise { - return new Promise((resolve, reject) => { - const cancelButtonText = i18n.translate('savedObjects.confirmModal.cancelButtonLabel', { - defaultMessage: 'Cancel', - }); - - const modal = overlays.openModal( - toMountPoint( - { - modal.close(); - reject(); - }} - onConfirm={() => { - modal.close(); - resolve(true); - }} - confirmButtonText={confirmBtnText} - cancelButtonText={cancelButtonText} - title={title} - > - {message} - , - startServices - ) - ); - }); -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts b/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts deleted file mode 100644 index 42d3c956cdad2..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts +++ /dev/null @@ -1,74 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { SavedObjectAttributes } from '@kbn/core/public'; -import { SavedObject, SavedObjectKibanaServices, StartServices } from '../../types'; -import { OVERWRITE_REJECTED } from '../../constants'; -import { confirmModalPromise } from './confirm_modal_promise'; - -/** - * Attempts to create the current object using the serialized source. If an object already - * exists, a warning message requests an overwrite confirmation. - * @param source - serialized version of this object (return value from this._serialize()) - * What will be indexed into elasticsearch. - * @param savedObject - savedObject - * @param esType - type of the saved object - * @param options - options to pass to the saved object create method - * @param services - provides Kibana services savedObjectsClient and overlays - * @returns {Promise} - A promise that is resolved with the objects id if the object is - * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with - * a confirmRejected = true parameter so that case can be handled differently than - * a create or index error. - * @resolved {SavedObject} - */ -export async function createSource( - source: SavedObjectAttributes, - savedObject: SavedObject, - esType: string, - options = {}, - services: SavedObjectKibanaServices, - startServices: StartServices -) { - const { savedObjectsClient, overlays } = services; - try { - return await savedObjectsClient.create(esType, source, options); - } catch (err) { - // record exists, confirm overwriting - if (get(err, 'res.status') === 409) { - const confirmMessage = i18n.translate( - 'savedObjects.confirmModal.overwriteConfirmationMessage', - { - defaultMessage: 'Are you sure you want to overwrite {title}?', - values: { title: savedObject.title }, - } - ); - - const title = i18n.translate('savedObjects.confirmModal.overwriteTitle', { - defaultMessage: 'Overwrite {name}?', - values: { name: savedObject.getDisplayName() }, - }); - const confirmButtonText = i18n.translate('savedObjects.confirmModal.overwriteButtonLabel', { - defaultMessage: 'Overwrite', - }); - - return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays, startServices) - .then(() => - savedObjectsClient.create( - esType, - source, - savedObject.creationOpts({ overwrite: true, ...options }) - ) - ) - .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); - } - return await Promise.reject(err); - } -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/display_duplicate_title_confirm_modal.ts b/src/plugins/saved_objects/public/saved_object/helpers/display_duplicate_title_confirm_modal.ts deleted file mode 100644 index 3dec7f93f1d19..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/display_duplicate_title_confirm_modal.ts +++ /dev/null @@ -1,38 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { i18n } from '@kbn/i18n'; -import { OverlayStart } from '@kbn/core/public'; -import { SAVE_DUPLICATE_REJECTED } from '../../constants'; -import { confirmModalPromise } from './confirm_modal_promise'; -import { SavedObject, StartServices } from '../../types'; - -export function displayDuplicateTitleConfirmModal( - savedObject: Pick, - overlays: OverlayStart, - startServices: StartServices -): Promise { - const confirmMessage = i18n.translate( - 'savedObjects.confirmModal.saveDuplicateConfirmationMessage', - { - defaultMessage: `A {name} with the title ''{title}'' already exists. Would you like to save anyway?`, - values: { title: savedObject.title, name: savedObject.getDisplayName() }, - } - ); - - const confirmButtonText = i18n.translate('savedObjects.confirmModal.saveDuplicateButtonLabel', { - defaultMessage: 'Save {name}', - values: { name: savedObject.getDisplayName() }, - }); - try { - return confirmModalPromise(confirmMessage, '', confirmButtonText, overlays, startServices); - } catch (_) { - return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)); - } -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/index.ts b/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/index.ts deleted file mode 100644 index 850352da2b188..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/index.ts +++ /dev/null @@ -1,11 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export type { FieldMappingSpec, MappingObject } from './types'; -export { expandShorthand } from './mapping_setup'; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.test.ts b/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.test.ts deleted file mode 100644 index dbf7d5c38eec3..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.test.ts +++ /dev/null @@ -1,45 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { expandShorthand } from './mapping_setup'; -import { ES_FIELD_TYPES } from '@kbn/data-plugin/public'; - -describe('mapping_setup', () => { - it('allows shortcuts for field types by just setting the value to the type name', () => { - const mapping = expandShorthand({ foo: ES_FIELD_TYPES.BOOLEAN }); - - expect(mapping.foo.type).toBe('boolean'); - }); - - it('can set type as an option', () => { - const mapping = expandShorthand({ foo: { type: ES_FIELD_TYPES.INTEGER } }); - - expect(mapping.foo.type).toBe('integer'); - }); - - describe('when type is json', () => { - it('returned object is type text', () => { - const mapping = expandShorthand({ foo: 'json' }); - - expect(mapping.foo.type).toBe('text'); - }); - - it('returned object has _serialize function', () => { - const mapping = expandShorthand({ foo: 'json' }); - - expect(mapping.foo._serialize).toBeInstanceOf(Function); - }); - - it('returned object has _deserialize function', () => { - const mapping = expandShorthand({ foo: 'json' }); - - expect(mapping.foo._serialize).toBeInstanceOf(Function); - }); - }); -}); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.ts b/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.ts deleted file mode 100644 index cc4de351eb3c0..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/mapping_setup.ts +++ /dev/null @@ -1,35 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { mapValues, isString } from 'lodash'; -import { ES_FIELD_TYPES } from '@kbn/data-plugin/public'; -import { FieldMappingSpec, MappingObject } from './types'; - -// import from ./common/types to prevent circular dependency of kibana_utils <-> data plugin - -/** @private */ -type ShorthandFieldMapObject = FieldMappingSpec | ES_FIELD_TYPES | 'json'; - -/** @public */ -export const expandShorthand = (sh: Record): MappingObject => { - return mapValues(sh, (val: ShorthandFieldMapObject) => { - const fieldMap = isString(val) ? { type: val } : val; - const json: FieldMappingSpec = { - type: ES_FIELD_TYPES.TEXT, - _serialize(v) { - if (v) return JSON.stringify(v); - }, - _deserialize(v) { - if (v) return JSON.parse(v); - }, - }; - - return fieldMap.type === 'json' ? json : fieldMap; - }) as MappingObject; -}; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/types.ts b/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/types.ts deleted file mode 100644 index 4a1b3e0d3892d..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/field_mapping/types.ts +++ /dev/null @@ -1,20 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { ES_FIELD_TYPES } from '@kbn/data-plugin/public'; - -/** @public */ -export interface FieldMappingSpec { - type: ES_FIELD_TYPES; - _serialize?: (mapping: any) => string | undefined; - _deserialize?: (mapping: string) => any | undefined; -} - -/** @public */ -export type MappingObject = Record; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.test.ts b/src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.test.ts deleted file mode 100644 index c570165ae1f81..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.test.ts +++ /dev/null @@ -1,38 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { findObjectByTitle } from './find_object_by_title'; -import { SavedObjectsClientContract, SavedObject } from '@kbn/core/public'; -import { simpleSavedObjectMock } from '@kbn/core/public/mocks'; - -describe('findObjectByTitle', () => { - const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; - - beforeEach(() => { - savedObjectsClient.find = jest.fn(); - }); - - it('returns undefined if title is not provided', async () => { - const match = await findObjectByTitle(savedObjectsClient, 'index-pattern', ''); - expect(match).toBeUndefined(); - }); - - it('matches any case', async () => { - const indexPattern = simpleSavedObjectMock.create(savedObjectsClient, { - attributes: { title: 'foo' }, - } as SavedObject); - savedObjectsClient.find = jest.fn().mockImplementation(() => - Promise.resolve({ - savedObjects: [indexPattern], - }) - ); - const match = await findObjectByTitle(savedObjectsClient, 'index-pattern', 'FOO'); - expect(match).toEqual(indexPattern); - }); -}); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.ts b/src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.ts deleted file mode 100644 index 2ec7cf89d7d34..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/find_object_by_title.ts +++ /dev/null @@ -1,45 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - SavedObjectsClientContract, - SimpleSavedObject, - SavedObjectAttributes, -} from '@kbn/core/public'; - -/** - * Returns an object matching a given title - * - * @param savedObjectsClient {SavedObjectsClientContract} - * @param type {string} - * @param title {string} - * @returns {Promise} - */ -export async function findObjectByTitle( - savedObjectsClient: SavedObjectsClientContract, - type: string, - title: string -): Promise | void> { - if (!title) { - return; - } - - // Elastic search will return the most relevant results first, which means exact matches should come - // first, and so we shouldn't need to request everything. Using 10 just to be on the safe side. - const response = await savedObjectsClient.find({ - type, - perPage: 10, - search: `"${title}"`, - searchFields: ['title'], - fields: ['title'], - }); - return response.savedObjects.find( - (obj) => obj.get('title').toLowerCase() === title.toLowerCase() - ); -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/hydrate_index_pattern.ts b/src/plugins/saved_objects/public/saved_object/helpers/hydrate_index_pattern.ts deleted file mode 100644 index 42de37dcb71d1..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/hydrate_index_pattern.ts +++ /dev/null @@ -1,40 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { DataViewsContract } from '@kbn/data-views-plugin/public'; -import { SavedObject, SavedObjectConfig } from '../../types'; - -/** - * After creation or fetching from ES, ensure that the searchSources index indexPattern - * is an bonafide IndexPattern object. - * - * @return {Promise} - */ -export async function hydrateIndexPattern( - id: string, - savedObject: SavedObject, - dataViews: DataViewsContract, - config: SavedObjectConfig -) { - const indexPattern = config.indexPattern; - - if (!savedObject.searchSource) { - return null; - } - - const index = id || indexPattern || savedObject.searchSource.getOwnField('index'); - - if (typeof index !== 'string' || !index) { - return null; - } - - const indexObj = await dataViews.get(index); - savedObject.searchSource.setField('index', indexObj); - return indexObj; -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts deleted file mode 100644 index a7428156176a5..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts +++ /dev/null @@ -1,50 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { cloneDeep, assign } from 'lodash'; -import { SavedObjectsClientContract } from '@kbn/core/public'; -import { SavedObject, SavedObjectConfig } from '../../types'; - -/** - * Initialize saved object - */ -export async function intializeSavedObject( - savedObject: SavedObject, - savedObjectsClient: SavedObjectsClientContract, - config: SavedObjectConfig -) { - const esType = config.type; - // ensure that the esType is defined - if (!esType) throw new Error('You must define a type name to use SavedObject objects.'); - - if (!savedObject.id) { - // just assign the defaults and be done - assign(savedObject, savedObject.defaults); - await savedObject.hydrateIndexPattern!(); - if (typeof config.afterESResp === 'function') { - savedObject = await config.afterESResp(savedObject); - } - return savedObject; - } - - const resp = await savedObjectsClient.get(esType, savedObject.id); - const respMapped = { - _id: resp.id, - _type: resp.type, - _source: cloneDeep(resp.attributes), - references: resp.references, - found: !!resp._version, - }; - await savedObject.applyESResp(respMapped); - if (typeof config.init === 'function') { - await config.init.call(savedObject); - } - - return savedObject; -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts index 76ac6bc3c0b33..429a96eb19f46 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts @@ -7,118 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - SavedObject, - SavedObjectConfig, - SavedObjectKibanaServices, - SavedObjectSaveOpts, - StartServices, -} from '../../types'; import { OVERWRITE_REJECTED, SAVE_DUPLICATE_REJECTED } from '../../constants'; -import { createSource } from './create_source'; -import { checkForDuplicateTitle } from './check_for_duplicate_title'; /** * @param error {Error} the error * @return {boolean} */ -export function isErrorNonFatal(error: { message: string }) { +export function isErrorNonFatal(error: { message: string }): boolean { if (!error) return false; return error.message === OVERWRITE_REJECTED || error.message === SAVE_DUPLICATE_REJECTED; } - -/** - * Saves this object. - * - * @param {string} [esType] - * @param {SavedObject} [savedObject] - * @param {SavedObjectConfig} [config] - * @param {object} [options={}] - * @property {boolean} [options.confirmOverwrite=false] - If true, attempts to create the source so it - * can confirm an overwrite if a document with the id already exists. - * @property {boolean} [options.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title - * @property {func} [options.onTitleDuplicate] - function called if duplicate title exists. - * When not provided, confirm modal will be displayed asking user to confirm or cancel save. - * @param {SavedObjectKibanaServices} [services] - * @param {StartServices} [startServices] - * @return {Promise} - * @resolved {String} - The id of the doc - */ -export async function saveSavedObject( - savedObject: SavedObject, - config: SavedObjectConfig, - { - confirmOverwrite = false, - isTitleDuplicateConfirmed = false, - onTitleDuplicate, - }: SavedObjectSaveOpts = {}, - services: SavedObjectKibanaServices, - startServices: StartServices -): Promise { - const { savedObjectsClient, chrome } = services; - - const esType = config.type || ''; - const extractReferences = config.extractReferences; - // Save the original id in case the save fails. - const originalId = savedObject.id; - // Read https://github.com/elastic/kibana/issues/9056 and - // https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable - // exists. - // The goal is to move towards a better rename flow, but since our users have been conditioned - // to expect a 'save as' flow during a rename, we are keeping the logic the same until a better - // UI/UX can be worked out. - if (savedObject.copyOnSave) { - delete savedObject.id; - } - - // Here we want to extract references and set them within "references" attribute - let { attributes, references } = savedObject._serialize(); - if (extractReferences) { - ({ attributes, references } = extractReferences({ attributes, references })); - } - if (!references) throw new Error('References not returned from extractReferences'); - - try { - await checkForDuplicateTitle( - savedObject, - isTitleDuplicateConfirmed, - onTitleDuplicate, - services, - startServices - ); - savedObject.isSaving = true; - const resp = confirmOverwrite - ? await createSource( - attributes, - savedObject, - esType, - savedObject.creationOpts({ references }), - services, - startServices - ) - : await savedObjectsClient.create( - esType, - attributes, - savedObject.creationOpts({ references, overwrite: true }) - ); - - savedObject.id = resp.id; - if (savedObject.showInRecentlyAccessed && savedObject.getFullPath) { - chrome.recentlyAccessed.add( - savedObject.getFullPath(), - savedObject.title, - String(savedObject.id) - ); - } - savedObject.isSaving = false; - savedObject.lastSavedTitle = savedObject.title; - return savedObject.id; - } catch (err) { - savedObject.isSaving = false; - savedObject.id = originalId; - if (isErrorNonFatal(err)) { - return ''; - } - return Promise.reject(err); - } -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts deleted file mode 100644 index 00c8a01026ef4..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts +++ /dev/null @@ -1,118 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { SavedObjectAttributes, SavedObjectsCreateOptions, OverlayStart } from '@kbn/core/public'; -import { SavedObjectsClientContract } from '@kbn/core/public'; -import { analyticsServiceMock, i18nServiceMock, themeServiceMock } from '@kbn/core/public/mocks'; -import { saveWithConfirmation } from './save_with_confirmation'; -import * as deps from './confirm_modal_promise'; -import { OVERWRITE_REJECTED } from '../../constants'; - -describe('saveWithConfirmation', () => { - const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; - const overlays: OverlayStart = {} as OverlayStart; - const source: SavedObjectAttributes = {} as SavedObjectAttributes; - const options: SavedObjectsCreateOptions = {} as SavedObjectsCreateOptions; - const savedObject = { - getEsType: () => 'test type', - title: 'test title', - displayName: 'test display name', - }; - const startServices = { - analytics: analyticsServiceMock.createAnalyticsServiceStart(), - i18n: i18nServiceMock.createStartContract(), - theme: themeServiceMock.createStartContract(), - }; - - beforeEach(() => { - savedObjectsClient.create = jest.fn(); - jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.resolve({} as any)); - }); - - test('should call create of savedObjectsClient', async () => { - await saveWithConfirmation( - source, - savedObject, - options, - { savedObjectsClient, overlays }, - startServices - ); - expect(savedObjectsClient.create).toHaveBeenCalledWith( - savedObject.getEsType(), - source, - options - ); - }); - - test('should call confirmModalPromise when such record exists', async () => { - savedObjectsClient.create = jest - .fn() - .mockImplementation((type, src, opt) => - opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) - ); - - await saveWithConfirmation( - source, - savedObject, - options, - { savedObjectsClient, overlays }, - startServices - ); - expect(deps.confirmModalPromise).toHaveBeenCalledWith( - expect.any(String), - expect.any(String), - expect.any(String), - overlays, - expect.objectContaining({ - analytics: expect.any(Object), - i18n: expect.any(Object), - theme: expect.any(Object), - }) - ); - }); - - test('should call create of savedObjectsClient when overwriting confirmed', async () => { - savedObjectsClient.create = jest - .fn() - .mockImplementation((type, src, opt) => - opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) - ); - - await saveWithConfirmation( - source, - savedObject, - options, - { savedObjectsClient, overlays }, - startServices - ); - expect(savedObjectsClient.create).toHaveBeenLastCalledWith(savedObject.getEsType(), source, { - overwrite: true, - ...options, - }); - }); - - test('should reject when overwriting denied', async () => { - savedObjectsClient.create = jest.fn().mockReturnValue(Promise.reject({ res: { status: 409 } })); - jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.reject()); - - expect.assertions(1); - await expect( - saveWithConfirmation( - source, - savedObject, - options, - { - savedObjectsClient, - overlays, - }, - startServices - ) - ).rejects.toThrow(OVERWRITE_REJECTED); - }); -}); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts deleted file mode 100644 index d80e2179b5329..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts +++ /dev/null @@ -1,79 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { - SavedObjectAttributes, - SavedObjectsCreateOptions, - OverlayStart, - SavedObjectsClientContract, -} from '@kbn/core/public'; -import { OVERWRITE_REJECTED } from '../../constants'; -import type { StartServices } from '../../types'; -import { confirmModalPromise } from './confirm_modal_promise'; - -/** - * Attempts to create the current object using the serialized source. If an object already - * exists, a warning message requests an overwrite confirmation. - * @param source - serialized version of this object what will be indexed into elasticsearch. - * @param savedObject - a simple object that contains properties title and displayName, and getEsType method - * @param options - options to pass to the saved object create method - * @param services - provides Kibana services savedObjectsClient and overlays - * @returns {Promise} - A promise that is resolved with the objects id if the object is - * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with - * a confirmRejected = true parameter so that case can be handled differently than - * a create or index error. - * @resolved {SavedObject} - */ -export async function saveWithConfirmation( - source: SavedObjectAttributes, - savedObject: { - getEsType(): string; - title: string; - displayName: string; - }, - options: SavedObjectsCreateOptions, - services: { savedObjectsClient: SavedObjectsClientContract; overlays: OverlayStart }, - startServices: StartServices -) { - const { savedObjectsClient, overlays } = services; - try { - return await savedObjectsClient.create(savedObject.getEsType(), source, options); - } catch (err) { - // record exists, confirm overwriting - if (get(err, 'res.status') === 409) { - const confirmMessage = i18n.translate( - 'savedObjects.confirmModal.overwriteConfirmationMessage', - { - defaultMessage: 'Are you sure you want to overwrite {title}?', - values: { title: savedObject.title }, - } - ); - - const title = i18n.translate('savedObjects.confirmModal.overwriteTitle', { - defaultMessage: 'Overwrite {name}?', - values: { name: savedObject.displayName }, - }); - const confirmButtonText = i18n.translate('savedObjects.confirmModal.overwriteButtonLabel', { - defaultMessage: 'Overwrite', - }); - - return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays, startServices) - .then(() => - savedObjectsClient.create(savedObject.getEsType(), source, { - overwrite: true, - ...options, - }) - ) - .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); - } - return await Promise.reject(err); - } -} diff --git a/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts deleted file mode 100644 index 3150f6ad3f005..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts +++ /dev/null @@ -1,55 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { forOwn } from 'lodash'; -import { extractSearchSourceReferences } from '@kbn/data-plugin/public'; -import { SavedObject, SavedObjectConfig } from '../../types'; -import { expandShorthand } from './field_mapping'; - -export function serializeSavedObject(savedObject: SavedObject, config: SavedObjectConfig) { - // mapping definition for the fields that this object will expose - const mapping = expandShorthand(config.mapping ?? {}); - const attributes = {} as Record; - const references = []; - - forOwn(mapping, (fieldMapping, fieldName) => { - if (typeof fieldName !== 'string') { - return; - } - // @ts-ignore - const savedObjectFieldVal = savedObject[fieldName]; - if (savedObjectFieldVal != null) { - attributes[fieldName] = fieldMapping._serialize - ? fieldMapping._serialize(savedObjectFieldVal) - : savedObjectFieldVal; - } - }); - - if (savedObject.searchSource) { - const { searchSourceJSON, references: searchSourceReferences } = - savedObject.searchSource.serialize(); - attributes.kibanaSavedObjectMeta = { searchSourceJSON }; - references.push(...searchSourceReferences); - } - - if (savedObject.searchSourceFields) { - const [searchSourceFields, searchSourceReferences] = extractSearchSourceReferences( - savedObject.searchSourceFields - ); - const searchSourceJSON = JSON.stringify(searchSourceFields); - attributes.kibanaSavedObjectMeta = { searchSourceJSON }; - references.push(...searchSourceReferences); - } - - if (savedObject.unresolvedIndexPatternReference) { - references.push(savedObject.unresolvedIndexPatternReference); - } - - return { attributes, references }; -} diff --git a/src/plugins/saved_objects/public/saved_object/index.ts b/src/plugins/saved_objects/public/saved_object/index.ts index 560178fdf4f36..68639d030317e 100644 --- a/src/plugins/saved_objects/public/saved_object/index.ts +++ b/src/plugins/saved_objects/public/saved_object/index.ts @@ -7,6 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { checkForDuplicateTitle } from './helpers/check_for_duplicate_title'; -export { saveWithConfirmation } from './helpers/save_with_confirmation'; export { isErrorNonFatal } from './helpers/save_saved_object'; diff --git a/src/plugins/saved_objects/public/types.ts b/src/plugins/saved_objects/public/types.ts index c34ef878b5d7e..0919c24ab2c62 100644 --- a/src/plugins/saved_objects/public/types.ts +++ b/src/plugins/saved_objects/public/types.ts @@ -7,16 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - ChromeStart, - CoreStart, - OverlayStart, - SavedObjectsClientContract, - SavedObjectAttributes, - SavedObjectReference, -} from '@kbn/core/public'; -import { ISearchSource, ISearchStart, SerializedSearchSourceFields } from '@kbn/data-plugin/public'; -import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { CoreStart, SavedObjectAttributes, SavedObjectReference } from '@kbn/core/public'; +import { ISearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; /** @@ -62,14 +54,6 @@ export interface SavedObjectCreationOpts { overwrite?: boolean; } -export interface SavedObjectKibanaServices { - savedObjectsClient: SavedObjectsClientContract; - dataViews: DataViewsContract; - search: ISearchStart; - chrome: ChromeStart; - overlays: OverlayStart; -} - export type StartServices = Pick; export interface SavedObjectAttributesAndRefs { diff --git a/src/plugins/saved_objects/tsconfig.json b/src/plugins/saved_objects/tsconfig.json index ccec7b43ad9f1..83e113a7e4e17 100644 --- a/src/plugins/saved_objects/tsconfig.json +++ b/src/plugins/saved_objects/tsconfig.json @@ -7,7 +7,6 @@ "kbn_references": [ "@kbn/core", "@kbn/data-plugin", - "@kbn/kibana-utils-plugin", "@kbn/i18n", "@kbn/data-views-plugin", "@kbn/i18n-react", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index e977ae228fdfc..5a5e006df3f28 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -6689,12 +6689,6 @@ "reporting.share.screenCapturePanelContent.optimizeForPrintingLabel": "Optimiser pour l'impression", "reporting.shareContextMenu.ExportsButtonLabel": "PDF", "reporting.shareContextMenu.ExportsButtonLabelPNG": "Export PNG", - "savedObjects.confirmModal.cancelButtonLabel": "Annuler", - "savedObjects.confirmModal.overwriteButtonLabel": "Écraser", - "savedObjects.confirmModal.overwriteConfirmationMessage": "Êtes-vous sûr de vouloir écraser {title} ?", - "savedObjects.confirmModal.overwriteTitle": "Écraser {name} ?", - "savedObjects.confirmModal.saveDuplicateButtonLabel": "Enregistrer {name}", - "savedObjects.confirmModal.saveDuplicateConfirmationMessage": "Il y a déjà une occurrence de {name} avec le titre \"{title}\". Voulez-vous tout de même enregistrer ?", "savedObjects.overwriteRejectedDescription": "La confirmation d'écrasement a été rejetée.", "savedObjects.saveDuplicateRejectedDescription": "La confirmation d'enregistrement avec un doublon de titre a été rejetée.", "savedObjects.saveModal.cancelButtonLabel": "Annuler", @@ -26193,7 +26187,6 @@ "xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel": "Ouvrir la fenêtre contextuelle", "xpack.inventory.data_view.creation_failed": "Une erreur s'est produite lors de la création de la vue de données", "xpack.inventory.eemEnablement.errorTitle": "Erreur lors de l'activation du nouveau modèle d'entité", - "xpack.inventory.entityActions.discoverLink": "Ouvrir dans Discover", "xpack.inventory.entitiesGrid.euiDataGrid.alertsLabel": "Alertes", "xpack.inventory.entitiesGrid.euiDataGrid.alertsTooltip": "Le nombre d'alertes actives", "xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel": "Nom de l'entité", @@ -26206,6 +26199,7 @@ "xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip": "Horodatage des dernières données reçues pour l'entité (entity.lastSeenTimestamp)", "xpack.inventory.entitiesGrid.euiDataGrid.typeLabel": "Type", "xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip": "Type d'entité (entity.type)", + "xpack.inventory.entityActions.discoverLink": "Ouvrir dans Discover", "xpack.inventory.featureRegistry.inventoryFeatureName": "Inventory", "xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "Alertes actives", "xpack.inventory.inventoryLinkTitle": "Inventory", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 05416471642cb..640d5d403d886 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6682,12 +6682,6 @@ "reporting.share.screenCapturePanelContent.optimizeForPrintingLabel": "印刷用に最適化", "reporting.shareContextMenu.ExportsButtonLabel": "PDF", "reporting.shareContextMenu.ExportsButtonLabelPNG": "PNGエクスポート", - "savedObjects.confirmModal.cancelButtonLabel": "キャンセル", - "savedObjects.confirmModal.overwriteButtonLabel": "上書き", - "savedObjects.confirmModal.overwriteConfirmationMessage": "{title}を上書きしてよろしいですか?", - "savedObjects.confirmModal.overwriteTitle": "{name} を上書きしますか?", - "savedObjects.confirmModal.saveDuplicateButtonLabel": "{name} を保存", - "savedObjects.confirmModal.saveDuplicateConfirmationMessage": "''{title}''というタイトルの {name} がすでに存在します。保存しますか?", "savedObjects.overwriteRejectedDescription": "上書き確認が拒否されました", "savedObjects.saveDuplicateRejectedDescription": "重複ファイルの保存確認が拒否されました", "savedObjects.saveModal.cancelButtonLabel": "キャンセル", @@ -26165,7 +26159,6 @@ "xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel": "ポップオーバーを開く", "xpack.inventory.data_view.creation_failed": "データビューの作成中にエラーが発生しました", "xpack.inventory.eemEnablement.errorTitle": "新しいエンティティモデルの有効化エラー", - "xpack.inventory.entityActions.discoverLink": "Discoverで開く", "xpack.inventory.entitiesGrid.euiDataGrid.alertsLabel": "アラート", "xpack.inventory.entitiesGrid.euiDataGrid.alertsTooltip": "アクティブなアラートの件数", "xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel": "エンティティ名", @@ -26178,6 +26171,7 @@ "xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip": "エンティティで最後に受信したデータのタイムスタンプ(entity.lastSeenTimestamp)", "xpack.inventory.entitiesGrid.euiDataGrid.typeLabel": "型", "xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip": "エンティティのタイプ(entity.type)", + "xpack.inventory.entityActions.discoverLink": "Discoverで開く", "xpack.inventory.featureRegistry.inventoryFeatureName": "インベントリ", "xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "アクティブアラート", "xpack.inventory.inventoryLinkTitle": "インベントリ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0e0b68b1a3ef9..991c23b73faa0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6601,8 +6601,6 @@ "reporting.share.screenCapturePanelContent.optimizeForPrintingLabel": "打印优化", "reporting.shareContextMenu.ExportsButtonLabel": "PDF", "reporting.shareContextMenu.ExportsButtonLabelPNG": "PNG 导出", - "savedObjects.confirmModal.cancelButtonLabel": "取消", - "savedObjects.confirmModal.overwriteButtonLabel": "覆盖", "savedObjects.overwriteRejectedDescription": "已拒绝覆盖确认", "savedObjects.saveDuplicateRejectedDescription": "已拒绝使用重复标题保存确认", "savedObjects.saveModal.cancelButtonLabel": "取消", @@ -25691,7 +25689,6 @@ "xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel": "打开弹出框", "xpack.inventory.data_view.creation_failed": "创建数据视图时出错", "xpack.inventory.eemEnablement.errorTitle": "启用新实体模型时出错", - "xpack.inventory.entityActions.discoverLink": "在 Discover 中打开", "xpack.inventory.entitiesGrid.euiDataGrid.alertsLabel": "告警", "xpack.inventory.entitiesGrid.euiDataGrid.alertsTooltip": "活动告警计数", "xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel": "实体名称", @@ -25704,6 +25701,7 @@ "xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip": "上次接收的实体数据的时间戳 (entity.lastSeenTimestamp)", "xpack.inventory.entitiesGrid.euiDataGrid.typeLabel": "类型", "xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip": "实体的类型 (entity.type)", + "xpack.inventory.entityActions.discoverLink": "在 Discover 中打开", "xpack.inventory.featureRegistry.inventoryFeatureName": "库存", "xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "活动告警", "xpack.inventory.inventoryLinkTitle": "库存", From 330b3800b05d3d31a7e1079ad309479f562d49f5 Mon Sep 17 00:00:00 2001 From: Elena Shostak <165678770+elena-shostak@users.noreply.github.com> Date: Tue, 19 Nov 2024 12:11:29 +0100 Subject: [PATCH 09/61] [FTR] Cleaned up check for removed apm_user role (#200679) ## Summary Cleaned up check for removed `apm_user` role, see https://github.com/elastic/elasticsearch/pull/116712 ### 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 - [x] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) __Closes: https://github.com/elastic/kibana/issues/200666__ --------- Co-authored-by: Elastic Machine --- x-pack/test/functional/apps/security/users.ts | 3 --- .../common/lib/authentication.ts | 4 --- .../common/lib/create_users_and_roles.ts | 10 -------- .../security_and_spaces/apis/get_all.ts | 25 ------------------- 4 files changed, 42 deletions(-) diff --git a/x-pack/test/functional/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index e9711dc29c46b..a8886045b70a4 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -111,9 +111,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(roles.apm_system.reserved).to.be(true); expect(roles.apm_system.deprecated).to.be(false); - expect(roles.apm_user.reserved).to.be(true); - expect(roles.apm_user.deprecated).to.be(true); - expect(roles.beats_admin.reserved).to.be(true); expect(roles.beats_admin.deprecated).to.be(false); diff --git a/x-pack/test/spaces_api_integration/common/lib/authentication.ts b/x-pack/test/spaces_api_integration/common/lib/authentication.ts index 27f644c3f5cd5..cbd261008dacb 100644 --- a/x-pack/test/spaces_api_integration/common/lib/authentication.ts +++ b/x-pack/test/spaces_api_integration/common/lib/authentication.ts @@ -90,10 +90,6 @@ export const AUTHENTICATION = { username: 'a_kibana_rbac_space_1_saved_objects_read_user', password: 'password', }, - APM_USER: { - username: 'a_apm_user', - password: 'password', - }, MACHINE_LEARING_ADMIN: { username: 'a_machine_learning_admin', password: 'password', diff --git a/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts b/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts index 2f93cc09fd032..f917a6efed15f 100644 --- a/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts +++ b/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts @@ -469,16 +469,6 @@ export const createUsersAndRoles = async (es: Client, supertest: SuperTestAgent) }, }); - await es.security.putUser({ - username: AUTHENTICATION.APM_USER.username, - body: { - password: AUTHENTICATION.APM_USER.password, - roles: ['apm_user'], - full_name: 'a apm user', - email: 'a_apm_user@elastic.co', - }, - }); - await es.security.putUser({ username: AUTHENTICATION.MACHINE_LEARING_ADMIN.username, body: { diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts index 1c2db5f6bcd7c..d40413f9457e3 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts @@ -57,7 +57,6 @@ export default function getAllSpacesTestSuite({ getService }: FtrProviderContext legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - apmUser: AUTHENTICATION.APM_USER, machineLearningAdmin: AUTHENTICATION.MACHINE_LEARING_ADMIN, machineLearningUser: AUTHENTICATION.MACHINE_LEARNING_USER, monitoringUser: AUTHENTICATION.MONITORING_USER, @@ -83,7 +82,6 @@ export default function getAllSpacesTestSuite({ getService }: FtrProviderContext legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - apmUser: AUTHENTICATION.APM_USER, machineLearningAdmin: AUTHENTICATION.MACHINE_LEARING_ADMIN, machineLearningUser: AUTHENTICATION.MACHINE_LEARNING_USER, monitoringUser: AUTHENTICATION.MONITORING_USER, @@ -484,29 +482,6 @@ export default function getAllSpacesTestSuite({ getService }: FtrProviderContext } ); - getAllTest(`apm_user can't access any spaces from ${scenario.spaceId}`, { - spaceId: scenario.spaceId, - user: scenario.users.apmUser, - tests: { - exists: { - statusCode: 403, - response: expectRbacForbidden, - }, - copySavedObjectsPurpose: { - statusCode: 403, - response: expectRbacForbidden, - }, - shareSavedObjectsPurpose: { - statusCode: 403, - response: expectRbacForbidden, - }, - includeAuthorizedPurposes: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - getAllTest(`machine_learning_admin can't access any spaces from ${scenario.spaceId}`, { spaceId: scenario.spaceId, user: scenario.users.machineLearningAdmin, From 2d970dc75d0f2a93c921b29d93d45c283acaf3d9 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 19 Nov 2024 11:13:10 +0000 Subject: [PATCH 10/61] [ML] Fixing edit calendar locator (#200681) Fixing a typo in the ML locator switch statement which is missing a `break` for the edit calendar page. Bug introduced in https://github.com/elastic/kibana/pull/193605 Currently there are no instances where the locator is used to find this page, so this is not something which will affect the user, but it needs still fixing. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --- x-pack/plugins/ml/public/locator/ml_locator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index f2807687110f6..0d3ba0189aa7a 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -125,6 +125,7 @@ export class MlLocatorDefinition implements LocatorDefinition { break; case ML_PAGES.CALENDARS_EDIT: path = formatEditCalendarUrl('', params.pageState); + break; case ML_PAGES.CALENDARS_DST_EDIT: path = formatEditCalendarDstUrl('', params.pageState); break; From 37b1dbb3ca5dbbbdce34dd2a663f43f0c1eb7a8f Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Nov 2024 11:36:36 +0000 Subject: [PATCH 11/61] [Ownership] Assign home plugin and helper (#200564) ## Summary Assign home plugin and helper. This pr is a follow up to a wrongly assigned entry within https://github.com/elastic/kibana/pull/200142 Contributes to: #192979 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 7 +++++-- src/plugins/home/kibana.jsonc | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 98f3aef6118ce..0894fbff896ad 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -659,7 +659,7 @@ src/plugins/files @elastic/appex-sharedux src/plugins/files_management @elastic/appex-sharedux src/plugins/ftr_apis @elastic/kibana-core src/plugins/guided_onboarding @elastic/appex-sharedux -src/plugins/home @elastic/kibana-core +src/plugins/home @elastic/appex-sharedux src/plugins/image_embeddable @elastic/appex-sharedux src/plugins/input_control_vis @elastic/kibana-presentation src/plugins/inspector @elastic/kibana-presentation @@ -1096,7 +1096,6 @@ x-pack/test_serverless/api_integration/test_suites/common/platform_security @ela src/plugins/discover/public/context_awareness/profile_providers/security @elastic/kibana-data-discovery @elastic/security-threat-hunting-investigations # Platform Docs -/x-pack/test/functional/services/sample_data @elastic/platform-docs /x-pack/test_serverless/functional/test_suites/security/screenshot_creation/index.ts @elastic/platform-docs /x-pack/test_serverless/functional/test_suites/security/config.screenshots.ts @elastic/platform-docs /x-pack/test/screenshot_creation @elastic/platform-docs @@ -2275,6 +2274,10 @@ x-pack/test/profiling_api_integration @elastic/obs-ux-infra_services-team x-pack/plugins/observability_solution/observability_shared/public/components/profiling @elastic/obs-ux-infra_services-team # Shared UX +/test/api_integration/apis/short_url/**/*.ts @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/200209/files#r1846654156 +/test/functional/page_objects/share_page.ts @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/200209/files#r1846648444 +/test/accessibility/apps/kibana_overview_* @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/200209/files/cab99bce5ac2082fa77222beebe3b61ff836b94b#r1846659920 +/x-pack/test/functional/services/sample_data @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/200142#discussion_r1846512756 /test/functional/page_objects/files_management.ts @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/200017#discussion_r1840477291 /test/accessibility/apps/home.ts @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/199771/files#r1840077237 /test/api_integration/apis/home/*.ts @elastic/appex-sharedux # Assigned per https://github.com/elastic/kibana/pull/199771/files#r1840077065 diff --git a/src/plugins/home/kibana.jsonc b/src/plugins/home/kibana.jsonc index deef8ba61fd73..55939278b6746 100644 --- a/src/plugins/home/kibana.jsonc +++ b/src/plugins/home/kibana.jsonc @@ -2,7 +2,7 @@ "type": "plugin", "id": "@kbn/home-plugin", "owner": [ - "@elastic/kibana-core" + "@elastic/appex-sharedux" ], "group": "platform", "visibility": "shared", @@ -25,4 +25,4 @@ "kibanaReact" ] } -} \ No newline at end of file +} From fe03c613c6807bfc4a24e7a82c81669be3f2310c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 19 Nov 2024 12:47:41 +0100 Subject: [PATCH 12/61] [FTR] Enhance log wrapper for page objects (#199810) ## Summary This small improvement reports the spent time for a given page object using the `logWrapper`. --- .../functional/page_objects/log_wrapper.ts | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/x-pack/test/functional/page_objects/log_wrapper.ts b/x-pack/test/functional/page_objects/log_wrapper.ts index 97f5a7a89369f..afcead60b2905 100644 --- a/x-pack/test/functional/page_objects/log_wrapper.ts +++ b/x-pack/test/functional/page_objects/log_wrapper.ts @@ -7,6 +7,10 @@ import { ToolingLog } from '@kbn/tooling-log'; +function isPromise(value: unknown): value is Promise { + return value instanceof Promise; +} + /** * Wraps the specified object instance with debug log statements of all method calls. * @@ -19,17 +23,45 @@ export function logWrapper>( log: ToolingLog, instance: T ): T { + const logger = prepareLogger(log, prefix); return Object.keys(instance).reduce((acc, prop) => { const baseFn = acc[prop]; (acc as Record)[prop] = (...args: unknown[]) => { - logMethodCall(log, prefix, prop, args); - return baseFn.apply(instance, args); + logger.start(prop, args); + const result = baseFn.apply(instance, args); + if (isPromise(result)) { + result.then(logger.end, logger.end); + } else { + logger.end(); + } + return result; }; return acc; }, instance); } -function logMethodCall(log: ToolingLog, prefix: string, prop: string, args: unknown[]) { - const argsStr = args.map((arg) => (typeof arg === 'string' ? `'${arg}'` : arg)).join(', '); - log.debug(`${prefix}.${prop}(${argsStr})`); +function prepareLogger(log: ToolingLog, prefix: string) { + let now = Date.now(); + let currentContext = ''; + + return { + start: (prop: string, args: unknown[]) => { + if (prop === '') { + return; + } + currentContext = `${prop}(${args + .map((arg) => (typeof arg === 'string' ? `'${arg}'` : JSON.stringify(arg))) + .join(', ')})`; + log.debug(`${prefix}.${currentContext}`); + now = Date.now(); + }, + end: () => { + if (currentContext === '') { + return; + } + log.debug(`${prefix}.${currentContext} - (Took ${Date.now() - now} ms)`); + now = Date.now(); + currentContext = ''; + }, + }; } From 75be9c30bff89a5b5433d69db6330eebe1fe9ae8 Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 06:09:06 -0600 Subject: [PATCH 13/61] Update dependency @xyflow/react to ^12.3.5 (main) (#200571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [@xyflow/react](https://reactflow.dev) ([source](https://togithub.com/xyflow/xyflow/tree/HEAD/packages/react)) | dependencies | patch | [`^12.3.4` -> `^12.3.5`](https://renovatebot.com/diffs/npm/@xyflow%2freact/12.3.4/12.3.5) | --- ### Release Notes
xyflow/xyflow (@​xyflow/react) ### [`v12.3.5`](https://togithub.com/xyflow/xyflow/blob/HEAD/packages/react/CHANGELOG.md#1235) [Compare Source](https://togithub.com/xyflow/xyflow/compare/@xyflow/react@12.3.4...@xyflow/react@12.3.5) ##### Patch Changes - [#​4789](https://togithub.com/xyflow/xyflow/pull/4789) [`358eb355`](https://togithub.com/xyflow/xyflow/commit/358eb355e0b2bea4ffa47b0f04d6edf834343cd7) Thanks [@​peterkogo](https://togithub.com/peterkogo)! - Support key combinations which include '+' (e.g., 'Control++' resolves to the combination 'Control' and '+'). - [#​4796](https://togithub.com/xyflow/xyflow/pull/4796) [`73402779`](https://togithub.com/xyflow/xyflow/commit/734027798799f4a98212dda115d33b4c54a95a45) Thanks [@​Aki-7](https://togithub.com/Aki-7)! - Fix number of issues connected to batching node & edge updates. - [#​4790](https://togithub.com/xyflow/xyflow/pull/4790) [`2fa9a920`](https://togithub.com/xyflow/xyflow/commit/2fa9a92042ba11986abbababb7e8b294e208d6cb) Thanks [@​peterkogo](https://togithub.com/peterkogo)! - Fix node dragging & resizing while zooming on flow that does not cover whole browser window. - [#​4782](https://togithub.com/xyflow/xyflow/pull/4782) [`323e1b35`](https://togithub.com/xyflow/xyflow/commit/323e1b35c58bca80deb824bc8b136705593a5257) Thanks [@​peterkogo](https://togithub.com/peterkogo)! - Fix node intersections in nested flow. - Updated dependencies \[[`2fa9a920`](https://togithub.com/xyflow/xyflow/commit/2fa9a92042ba11986abbababb7e8b294e208d6cb), [`323e1b35`](https://togithub.com/xyflow/xyflow/commit/323e1b35c58bca80deb824bc8b136705593a5257)]: - [@​xyflow/system](https://togithub.com/xyflow/system)[@​0](https://togithub.com/0).0.46
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index a8c60004e6040..ebbf26f25002c 100644 --- a/package.json +++ b/package.json @@ -1068,7 +1068,7 @@ "@turf/length": "^6.0.2", "@xstate/react": "^3.2.2", "@xstate5/react": "npm:@xstate/react@^4.1.2", - "@xyflow/react": "^12.3.4", + "@xyflow/react": "^12.3.5", "adm-zip": "^0.5.9", "ai": "^2.2.33", "ajv": "^8.12.0", diff --git a/yarn.lock b/yarn.lock index 719e45bb7cab7..79da7243edd61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12127,19 +12127,19 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -"@xyflow/react@^12.3.4": - version "12.3.4" - resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.3.4.tgz#cccc57f7a992faecc5ed1dda82838b31c1afa522" - integrity sha512-KjFkj84S+wK8aJF/PORxSkOAeotTTPz++hus+Y95NFMIJGVyl8jjVaaz5B1zyV0prk6ZkbMp6q0vqMjJdZT25Q== +"@xyflow/react@^12.3.5": + version "12.3.5" + resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.3.5.tgz#88ca2efe2ddf1300bc171a2ef797f7cb41386ca4" + integrity sha512-wAYqpicdrVo1rxCu0X3M9s3YIF45Agqfabw0IBryTGqjWvr2NyfciI8gIP4MB+NKpWWN5kxZ9tiZ9u8lwC7iAg== dependencies: - "@xyflow/system" "0.0.45" + "@xyflow/system" "0.0.46" classcat "^5.0.3" zustand "^4.4.0" -"@xyflow/system@0.0.45": - version "0.0.45" - resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.45.tgz#ca1f4d843d2925ce9c5763f16abf51a4c69953ef" - integrity sha512-szP1LjDD4jlRYYhxvgZqOCTMToUVNqjQkrlhb0fhv1sXomU1+yMDdhpQT+FjE4d+rKx08fS10sOuZUl2ycXaDw== +"@xyflow/system@0.0.46": + version "0.0.46" + resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.46.tgz#b0a5915d59c0ea5ca6d24e1eb90c5a0d7eda7864" + integrity sha512-bmFXvboVdiydIFZmDCjrbBCYgB0d5pYdkcZPWbAxGmhMRUZ+kW3CksYgYxWabrw51rwpWitLEadvLrivG0mVfA== dependencies: "@types/d3-drag" "^3.0.7" "@types/d3-selection" "^3.0.10" From 2dc1ce3151bf075c5a5675fed05f4816417f63ab Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Nov 2024 23:18:49 +1100 Subject: [PATCH 14/61] Authorized route migration for routes owned by @elastic/kibana-cloud-security-posture (#198189) ### Authz API migration for authorized routes This PR migrates `access:` tags used in route definitions to new security configuration. Please refer to the documentation for more information: [Authorization API](https://docs.elastic.dev/kibana-dev-docs/key-concepts/security-api-authorization) ### **Before migration:** Access control tags were defined in the `options` object of the route: ```ts router.get({ path: '/api/path', options: { tags: ['access:', 'access:'], }, ... }, handler); ``` ### **After migration:** Tags have been replaced with the more robust `security.authz.requiredPrivileges` field under `security`: ```ts router.get({ path: '/api/path', security: { authz: { requiredPrivileges: ['', ''], }, }, ... }, handler); ``` ### What to do next? 1. Review the changes in this PR. 2. You might need to update your tests to reflect the new security configuration: - If you have tests that rely on checking `access` tags. - If you have snapshot tests that include the route definition. - If you have FTR tests that rely on checking unauthorized error message. The error message changed to also include missing privileges. ## Any questions? If you have any questions or need help with API authorization, please reach out to the `@elastic/kibana-security` team. --------- Co-authored-by: Paulo Silva --- .../plugins/cloud_defend/server/routes/policies/policies.ts | 6 ++++-- x-pack/plugins/cloud_defend/server/routes/status/status.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts b/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts index ad40dcf3b693f..48a86733dc0e4 100644 --- a/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts +++ b/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts @@ -60,8 +60,10 @@ export const defineGetPoliciesRoute = (router: CloudDefendRouter) => .get({ access: 'internal', path: POLICIES_ROUTE_PATH, - options: { - tags: ['access:cloud-defend-read'], + security: { + authz: { + requiredPrivileges: ['cloud-defend-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_defend/server/routes/status/status.ts b/x-pack/plugins/cloud_defend/server/routes/status/status.ts index ff422638f1c8b..d7ba84d2d2108 100644 --- a/x-pack/plugins/cloud_defend/server/routes/status/status.ts +++ b/x-pack/plugins/cloud_defend/server/routes/status/status.ts @@ -218,8 +218,10 @@ export const defineGetCloudDefendStatusRoute = (router: CloudDefendRouter) => .get({ access: 'internal', path: STATUS_ROUTE_PATH, - options: { - tags: ['access:cloud-defend-read'], + security: { + authz: { + requiredPrivileges: ['cloud-defend-read'], + }, }, }) .addVersion({ version: '1', validate: {} }, async (context, request, response) => { From d14c03e22d218d79518255a4a14f92d610d77354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Gonz=C3=A1lez?= Date: Tue, 19 Nov 2024 13:20:00 +0100 Subject: [PATCH 15/61] [Search][ES3][Coming soon pages] Connectors and Web crawlers coming soon pages (#199284) ## Summary This PR exposes upcoming Elastic managed connectors and Elastic managed web crawlers in 2 different coming soon pages. From the connectors journey, the initial empty state will perform as a shuttler to let users choose between Self-managed and Elastic managed approaches. The self-managed CTA button will take us to the current connectors creation flow in ES3. The secondary CTA will take us to the Elastic managed coming soon page. Once users would have any connector created the initial connectors empty state will go away. But we will let users come back to the Elastic managed connector coming soon page showing a Callout on top of the connectors list. From the Web crawlers journey, the initial empty state let us choose also between Self-managed and Elastic managed web crawlers approaches. The first one will take us to the Open web crawler repo and the second CTA button will take us to the Elastic managed web crawler coming soon page. ![CleanShot 2024-11-06 at 13 48 29](https://github.com/user-attachments/assets/c4bcf6e2-cb94-42c4-8bf6-69d2bcd23bae) ### Tasks - [x] Modify current connectors empty state to become the landing to go for Self-managed or Elastic managed connectors - [x] Create the Elastic managed connectors coming soon page - [x] Create the Web crawlers empty state page - [x] Create the Elastic managed Web crawlers coming soon page - [x] Add the Web crawlers navigation entry point - [x] Add a Callout when listing existing connectors to take users to the Elastic managed coming soon page. - [x] Callout issue 1: Clicking Callout CTA button doesn't take us to that page, it looks like a React router issue. - [x] Callout issue 2: Handle the dismissible state persistency - [ ] Validate that we can capture required telemetry ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#_add_your_labels) - [ ] This will appear in the **Release Notes** and follow the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/deeplinks/search/constants.ts | 1 + packages/deeplinks/search/deep_links.ts | 3 + .../connectors/crawler_empty_state.tsx | 3 +- .../serverless_search/common/i18n_string.ts | 7 + .../common/decorative_horizontal_stepper.tsx | 45 +++ .../components/connectors/connector_icon.tsx | 19 + .../elastic_managed_connector_coming_soon.tsx | 196 ++++++++++ .../connectors/empty_connectors_prompt.tsx | 345 +++++++++++------- .../components/connectors_elastic_managed.tsx | 63 ++++ .../components/connectors_overview.tsx | 133 ++++--- .../components/connectors_router.tsx | 5 + ...lastic_managed_web_crawler_coming_soon.tsx | 171 +++++++++ .../empty_web_crawlers_prompt.tsx | 269 ++++++++++++++ .../web_crawlers_elastic_managed.tsx | 62 ++++ .../components/web_crawlers_overview.tsx | 62 ++++ .../components/web_crawlers_router.tsx | 24 ++ .../public/application/constants.ts | 3 + .../public/application/web_crawlers.tsx | 46 +++ .../public/assets/connectors.svg | 12 +- .../public/assets/web_crawlers.svg | 4 + .../public/navigation_tree.ts | 6 +- .../serverless_search/public/plugin.ts | 21 ++ .../plugins/serverless_search/tsconfig.json | 1 + .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../svl_search_connectors_page.ts | 6 +- .../test_suites/search/navigation.ts | 2 + 28 files changed, 1320 insertions(+), 195 deletions(-) create mode 100644 x-pack/plugins/serverless_search/public/application/components/common/decorative_horizontal_stepper.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/connectors/connector_icon.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/connectors/elastic_managed_connector_coming_soon.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/connectors_elastic_managed.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/web_crawlers/elastic_managed_web_crawler_coming_soon.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/web_crawlers/empty_web_crawlers_prompt.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/web_crawlers_elastic_managed.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/web_crawlers_overview.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/components/web_crawlers_router.tsx create mode 100644 x-pack/plugins/serverless_search/public/application/web_crawlers.tsx create mode 100644 x-pack/plugins/serverless_search/public/assets/web_crawlers.svg diff --git a/packages/deeplinks/search/constants.ts b/packages/deeplinks/search/constants.ts index 52f7bb201388e..6d9f6492abda7 100644 --- a/packages/deeplinks/search/constants.ts +++ b/packages/deeplinks/search/constants.ts @@ -16,6 +16,7 @@ export const ENTERPRISE_SEARCH_APPSEARCH_APP_ID = 'appSearch'; export const ENTERPRISE_SEARCH_WORKPLACESEARCH_APP_ID = 'workplaceSearch'; export const SERVERLESS_ES_APP_ID = 'serverlessElasticsearch'; export const SERVERLESS_ES_CONNECTORS_ID = 'serverlessConnectors'; +export const SERVERLESS_ES_WEB_CRAWLERS_ID = 'serverlessWebCrawlers'; export const SERVERLESS_ES_SEARCH_PLAYGROUND_ID = 'searchPlayground'; export const SERVERLESS_ES_SEARCH_INFERENCE_ENDPOINTS_ID = 'searchInferenceEndpoints'; export const SEARCH_HOMEPAGE = 'searchHomepage'; diff --git a/packages/deeplinks/search/deep_links.ts b/packages/deeplinks/search/deep_links.ts index b23a86b3cc51c..9dc9a8ed18203 100644 --- a/packages/deeplinks/search/deep_links.ts +++ b/packages/deeplinks/search/deep_links.ts @@ -10,6 +10,7 @@ import { SERVERLESS_ES_APP_ID, SERVERLESS_ES_CONNECTORS_ID, + SERVERLESS_ES_WEB_CRAWLERS_ID, ENTERPRISE_SEARCH_APP_ID, ENTERPRISE_SEARCH_CONTENT_APP_ID, ENTERPRISE_SEARCH_APPLICATIONS_APP_ID, @@ -38,6 +39,7 @@ export type EnterpriseSearchAppsearchApp = typeof ENTERPRISE_SEARCH_APPSEARCH_AP export type EnterpriseSearchWorkplaceSearchApp = typeof ENTERPRISE_SEARCH_WORKPLACESEARCH_APP_ID; export type ServerlessSearchApp = typeof SERVERLESS_ES_APP_ID; export type ConnectorsId = typeof SERVERLESS_ES_CONNECTORS_ID; +export type ServerlessWebCrawlers = typeof SERVERLESS_ES_WEB_CRAWLERS_ID; export type SearchPlaygroundId = typeof SERVERLESS_ES_SEARCH_PLAYGROUND_ID; export type SearchInferenceEndpointsId = typeof SERVERLESS_ES_SEARCH_INFERENCE_ENDPOINTS_ID; export type SearchHomepage = typeof SEARCH_HOMEPAGE; @@ -68,6 +70,7 @@ export type DeepLinkId = | EnterpriseSearchWorkplaceSearchApp | ServerlessSearchApp | ConnectorsId + | ServerlessWebCrawlers | SearchPlaygroundId | SearchInferenceEndpointsId | SearchHomepage diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/crawler_empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/crawler_empty_state.tsx index 5a03d0560dfbf..6be21dd8c63a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/crawler_empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/crawler_empty_state.tsx @@ -11,7 +11,6 @@ import { useValues } from 'kea'; import { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { CRAWLER } from '../../../../../common/constants'; import { HttpLogic } from '../../../shared/http'; import { GithubIcon } from '../../../shared/icons/github_icon'; import { KibanaLogic } from '../../../shared/kibana'; @@ -49,7 +48,7 @@ export const CrawlerEmptyState: React.FC = () => { color="primary" fill iconType={GithubIcon} - href={CRAWLER.github_repo} + href={'https://github.com/elastic/crawler'} > {i18n.translate( 'xpack.enterpriseSearch.crawlerEmptyState.openSourceCrawlerButtonLabel', diff --git a/x-pack/plugins/serverless_search/common/i18n_string.ts b/x-pack/plugins/serverless_search/common/i18n_string.ts index cf0dbad5277c8..32ec0cf8eb957 100644 --- a/x-pack/plugins/serverless_search/common/i18n_string.ts +++ b/x-pack/plugins/serverless_search/common/i18n_string.ts @@ -65,6 +65,13 @@ export const CONNECTOR_LABEL: string = i18n.translate('xpack.serverlessSearch.co defaultMessage: 'Connector', }); +export const WEB_CRAWLERS_LABEL: string = i18n.translate( + 'xpack.serverlessSearch.webCrawlersLabel', + { + defaultMessage: 'Web crawlers', + } +); + export const DELETE_CONNECTOR_LABEL = i18n.translate( 'xpack.serverlessSearch.connectors.deleteConnectorLabel', { diff --git a/x-pack/plugins/serverless_search/public/application/components/common/decorative_horizontal_stepper.tsx b/x-pack/plugins/serverless_search/public/application/components/common/decorative_horizontal_stepper.tsx new file mode 100644 index 0000000000000..99711dcb9c71e --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/common/decorative_horizontal_stepper.tsx @@ -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 React from 'react'; +import { EuiStepsHorizontal, EuiStepsHorizontalProps } from '@elastic/eui'; +import { css } from '@emotion/react'; + +interface DecorativeHorizontalStepperProps { + stepCount?: number; +} + +export const DecorativeHorizontalStepper: React.FC = ({ + stepCount = 2, +}) => { + // Generate the steps dynamically based on the stepCount prop + const horizontalSteps: EuiStepsHorizontalProps['steps'] = Array.from( + { length: stepCount }, + (_, index) => ({ + title: '', + status: 'incomplete', + onClick: () => {}, + }) + ); + + return ( + /* This is a presentational component, not intended for user interaction: + pointer-events: none, prevents user interaction with the component. + inert prevents click, focus, and other interactive events, removing it from the tab order. + role="presentation" indicates that this component is purely decorative and not interactive for assistive technologies. + Together, these attributes help ensure the component is accesible. */ + + ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_icon.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_icon.tsx new file mode 100644 index 0000000000000..7ae2961ef10b8 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_icon.tsx @@ -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 React from 'react'; +import { EuiToolTip, EuiIcon } from '@elastic/eui'; + +export const ConnectorIcon: React.FC<{ name: string; serviceType: string; iconPath?: string }> = ({ + name, + serviceType, + iconPath, +}) => ( + + + +); diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/elastic_managed_connector_coming_soon.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/elastic_managed_connector_coming_soon.tsx new file mode 100644 index 0000000000000..3057c6806fd73 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/connectors/elastic_managed_connector_coming_soon.tsx @@ -0,0 +1,196 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiIcon, + EuiTitle, + EuiText, + EuiBadge, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +// import { generatePath } from 'react-router-dom'; +import { SERVERLESS_ES_CONNECTORS_ID } from '@kbn/deeplinks-search/constants'; +import { useKibanaServices } from '../../hooks/use_kibana'; +import { useConnectorTypes } from '../../hooks/api/use_connector_types'; +import { useAssetBasePath } from '../../hooks/use_asset_base_path'; + +import { BACK_LABEL } from '../../../../common/i18n_string'; +// import { BASE_CONNECTORS_PATH } from '../../constants'; +import { ConnectorIcon } from './connector_icon'; +import { DecorativeHorizontalStepper } from '../common/decorative_horizontal_stepper'; + +export const ElasticManagedConnectorComingSoon: React.FC = () => { + const connectorTypes = useConnectorTypes(); + + const connectorExamples = connectorTypes.filter((connector) => + ['Gmail', 'Sharepoint Online', 'Jira Cloud', 'Dropbox'].includes(connector.name) + ); + + const { + application: { navigateToApp }, + } = useKibanaServices(); + + const assetBasePath = useAssetBasePath(); + const connectorsIcon = assetBasePath + '/connectors.svg'; + return ( + + + + + + navigateToApp(SERVERLESS_ES_CONNECTORS_ID)} + > + {BACK_LABEL} + + + + + + + +

+ {i18n.translate('xpack.serverlessSearch.elasticManagedConnectorEmpty.title', { + defaultMessage: 'Elastic managed connectors', + })} +

+
+
+ + Coming soon + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.elasticManagedConnectorEmpty.description', + { + defaultMessage: + "We're actively developing Elastic managed connectors, that won't require any self-managed infrastructure. You'll be able to handle all configuration in the UI. This will simplify syncing your data into a serverless Elasticsearch project. This new workflow will have two steps:", + } + )} +

+
+
+ + + + + + + + + + + + {connectorExamples.map((connector, index) => ( + + {index === Math.floor(connectorExamples.length / 2) && ( + + + + )} + + + + + ))} + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.elasticManagedConnectorEmpty.guideOneDescription', + { + defaultMessage: + "Choose from over 30 third-party data sources you'd like to sync", + } + )} +

+
+
+
+
+ + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.elasticManagedConnectorEmpty.guideThreeDescription', + { + defaultMessage: + 'Enter access and connection details for your data source and run your first sync using the Kibana UI', + } + )} +

+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx index 56c7a9aaf8155..0767f8cfaf276 100644 --- a/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/connectors/empty_connectors_prompt.tsx @@ -14,30 +14,49 @@ import { EuiText, EuiLink, EuiButton, - EuiToolTip, + EuiBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../common/doc_links'; +import { useKibanaServices } from '../../hooks/use_kibana'; import { useConnectorTypes } from '../../hooks/api/use_connector_types'; import { useCreateConnector } from '../../hooks/api/use_create_connector'; import { useAssetBasePath } from '../../hooks/use_asset_base_path'; import { useConnectors } from '../../hooks/api/use_connectors'; +import { DecorativeHorizontalStepper } from '../common/decorative_horizontal_stepper'; +import { ConnectorIcon } from './connector_icon'; + +import { ELASTIC_MANAGED_CONNECTOR_PATH, BASE_CONNECTORS_PATH } from '../../constants'; export const EmptyConnectorsPrompt: React.FC = () => { const connectorTypes = useConnectorTypes(); + + const connectorExamples = connectorTypes.filter((connector) => + ['Gmail', 'Sharepoint Online', 'Jira Cloud', 'Dropbox'].includes(connector.name) + ); const { createConnector, isLoading } = useCreateConnector(); const { data } = useConnectors(); const assetBasePath = useAssetBasePath(); const connectorsPath = assetBasePath + '/connectors.svg'; + + const { + application: { navigateToUrl }, + } = useKibanaServices(); + return ( - + @@ -45,13 +64,13 @@ export const EmptyConnectorsPrompt: React.FC = () => {

{i18n.translate('xpack.serverlessSearch.connectorsEmpty.title', { - defaultMessage: 'Create a connector', + defaultMessage: 'Set up a new connector', })}

- +

{i18n.translate('xpack.serverlessSearch.connectorsEmpty.description', { defaultMessage: @@ -60,155 +79,215 @@ export const EmptyConnectorsPrompt: React.FC = () => {

- - - - - - - - - - -

- {i18n.translate( - 'xpack.serverlessSearch.connectorsEmpty.guideOneDescription', - { - defaultMessage: "Choose a data source you'd like to sync", - } - )} -

-
-
-
+ + + + + - - - - - - - -

- - {i18n.translate( - 'xpack.serverlessSearch.connectorsEmpty.sourceLabel', - { defaultMessage: 'source' } - )} - - ), - docker: ( - - {i18n.translate( - 'xpack.serverlessSearch.connectorsEmpty.dockerLabel', - { defaultMessage: 'Docker' } - )} - - ), - }} - /> -

-
-
-
-
- - - + + + + + + {connectorExamples.map((connector, index) => ( + + {index === Math.floor(connectorExamples.length / 2) && ( + + + + )} + + + + + ))} + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.connectorsEmpty.guideOneDescription', + { + defaultMessage: + "Choose from over 30 third-party data sources you'd like to sync", + } + )} +

+
+
+
+
+ + - - - - - - - + - + - - - -

- {i18n.translate( - 'xpack.serverlessSearch.connectorsEmpty.guideThreeDescription', - { - defaultMessage: - 'Enter access and connection details for your data source and run your first sync', - } - )} -

-
-
-
+ + +

+ + {i18n.translate( + 'xpack.serverlessSearch.connectorsEmpty.sourceLabel', + { defaultMessage: 'source' } + )} + + ), + docker: ( + + {i18n.translate( + 'xpack.serverlessSearch.connectorsEmpty.dockerLabel', + { defaultMessage: 'Docker' } + )} + + ), + }} + /> +

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.connectorsEmpty.guideThreeDescription', + { + defaultMessage: + 'Enter access and connection details for your data source and run your first sync', + } + )} +

+
+
+
+
+
+
+
+
+ + + createConnector()} + isLoading={isLoading} + > + {i18n.translate('xpack.serverlessSearch.connectorsEmpty.selfManagedButton', { + defaultMessage: 'Self-managed connector', + })} + + + + + + + navigateToUrl(`${BASE_CONNECTORS_PATH}/${ELASTIC_MANAGED_CONNECTOR_PATH}`) + } + > + {i18n.translate( + 'xpack.serverlessSearch.connectorsEmpty.elasticManagedButton', + { + defaultMessage: 'Elastic managed connector', + } + )} + + + + Coming soon - - - - createConnector()} - isLoading={isLoading} - > - {i18n.translate('xpack.serverlessSearch.connectorsEmpty.createConnector', { - defaultMessage: 'Create connector', - })} - - + + - - -

- {i18n.translate('xpack.serverlessSearch.connectorsEmpty.availableConnectors', { - defaultMessage: 'Available connectors', - })} -

-
-
- - - {connectorTypes.map((connectorType) => ( - - - - - - ))} - - ); }; diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors_elastic_managed.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors_elastic_managed.tsx new file mode 100644 index 0000000000000..e645ede3d67e8 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/connectors_elastic_managed.tsx @@ -0,0 +1,63 @@ +/* + * 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 { EuiLink, EuiPageTemplate, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; + +import { LEARN_MORE_LABEL } from '../../../common/i18n_string'; + +import { useKibanaServices } from '../hooks/use_kibana'; +import { ElasticManagedConnectorComingSoon } from './connectors/elastic_managed_connector_coming_soon'; + +import { docLinks } from '../../../common/doc_links'; + +export const ConnectorsElasticManaged = () => { + const { console: consolePlugin } = useKibanaServices(); + + const embeddableConsole = useMemo( + () => (consolePlugin?.EmbeddableConsole ? : null), + [consolePlugin] + ); + + return ( + + + +

+ + {LEARN_MORE_LABEL} + + ), + }} + /> +

+
+
+ + + + {embeddableConsole} +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors_overview.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors_overview.tsx index f059a8d531eac..775cec8db1551 100644 --- a/x-pack/plugins/serverless_search/public/application/components/connectors_overview.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/connectors_overview.tsx @@ -7,30 +7,36 @@ import { EuiButton, + EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiLink, EuiPageTemplate, + EuiSpacer, EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; +import { GithubLink } from '@kbn/search-api-panels'; import { docLinks } from '../../../common/doc_links'; import { LEARN_MORE_LABEL } from '../../../common/i18n_string'; -import { PLUGIN_ID } from '../../../common'; import { useConnectors } from '../hooks/api/use_connectors'; import { useCreateConnector } from '../hooks/api/use_create_connector'; import { useKibanaServices } from '../hooks/use_kibana'; import { EmptyConnectorsPrompt } from './connectors/empty_connectors_prompt'; import { ConnectorsTable } from './connectors/connectors_table'; import { ConnectorPrivilegesCallout } from './connectors/connector_config/connector_privileges_callout'; +import { useAssetBasePath } from '../hooks/use_asset_base_path'; + +import { BASE_CONNECTORS_PATH, ELASTIC_MANAGED_CONNECTOR_PATH } from '../constants'; + +const CALLOUT_KEY = 'search.connectors.ElasticManaged.ComingSoon.feedbackCallout'; export const ConnectorsOverview = () => { const { data, isLoading: connectorsLoading } = useConnectors(); - const { http, console: consolePlugin } = useKibanaServices(); + const { console: consolePlugin } = useKibanaServices(); const { createConnector, isLoading } = useCreateConnector(); const embeddableConsole = useMemo( () => (consolePlugin?.EmbeddableConsole ? : null), @@ -39,6 +45,18 @@ export const ConnectorsOverview = () => { const canManageConnectors = !data || data.canManageConnectors; + const { + application: { navigateToUrl }, + } = useKibanaServices(); + + const [showCallOut, setShowCallOut] = useState(sessionStorage.getItem(CALLOUT_KEY) !== 'hidden'); + + const onDismiss = () => { + setShowCallOut(false); + sessionStorage.setItem(CALLOUT_KEY, 'hidden'); + }; + const assetBasePath = useAssetBasePath(); + return ( { })} data-test-subj="serverlessSearchConnectorsTitle" restrictWidth - rightSideItems={[ - - - - - - - - - 0 + ? [ + + + + + + createConnector()} > - {i18n.translate('xpack.serverlessSearch.connectorsPythonLink', { - defaultMessage: 'elastic/connectors', + {i18n.translate('xpack.serverlessSearch.connectors.createConnector', { + defaultMessage: 'Create connector', })} - - - - - - - createConnector()} - > - {i18n.translate('xpack.serverlessSearch.connectors.createConnector', { - defaultMessage: 'Create connector', - })} - - - , - ]} + + + , + ] + : undefined + } >

@@ -103,7 +107,6 @@ export const ConnectorsOverview = () => { learnMoreLink: ( @@ -118,7 +121,39 @@ export const ConnectorsOverview = () => { {connectorsLoading || (data?.connectors || []).length > 0 ? ( - + <> + {showCallOut && ( + <> + +

+ {i18n.translate( + 'xpack.serverlessSearch.connectorsOverview.calloutDescription', + { + defaultMessage: + "We're actively developing Elastic managed connectors, that won't require any self-managed infrastructure. You'll be able to handle all configuration in the UI. This will simplify syncing your data into a serverless Elasticsearch project.", + } + )} +

+ + navigateToUrl(`${BASE_CONNECTORS_PATH}/${ELASTIC_MANAGED_CONNECTOR_PATH}`) + } + > + {LEARN_MORE_LABEL} + + + + + )} + + ) : ( )} diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx index f8c224ed2c9c6..4085b812d1f3d 100644 --- a/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/connectors_router.tsx @@ -9,10 +9,15 @@ import { Route, Routes } from '@kbn/shared-ux-router'; import React from 'react'; import { EditConnector } from './connectors/edit_connector'; import { ConnectorsOverview } from './connectors_overview'; +import { ConnectorsElasticManaged } from './connectors_elastic_managed'; +import { ELASTIC_MANAGED_CONNECTOR_PATH } from '../constants'; export const ConnectorsRouter: React.FC = () => { return ( + + + diff --git a/x-pack/plugins/serverless_search/public/application/components/web_crawlers/elastic_managed_web_crawler_coming_soon.tsx b/x-pack/plugins/serverless_search/public/application/components/web_crawlers/elastic_managed_web_crawler_coming_soon.tsx new file mode 100644 index 0000000000000..ba146ed847990 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/web_crawlers/elastic_managed_web_crawler_coming_soon.tsx @@ -0,0 +1,171 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiIcon, + EuiTitle, + EuiText, + EuiBadge, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useKibanaServices } from '../../hooks/use_kibana'; +import { useAssetBasePath } from '../../hooks/use_asset_base_path'; + +import { BACK_LABEL } from '../../../../common/i18n_string'; +import { DecorativeHorizontalStepper } from '../common/decorative_horizontal_stepper'; + +export const ElasticManagedWebCrawlersCommingSoon: React.FC = () => { + const { + application: { navigateToUrl }, + } = useKibanaServices(); + + const assetBasePath = useAssetBasePath(); + const webCrawlerIcon = assetBasePath + '/web_crawlers.svg'; + + return ( + + + + + + navigateToUrl(`./`)} + > + {BACK_LABEL} + + + + + + + +

+ {i18n.translate('xpack.serverlessSearch.elasticManagedWebCrawlerEmpty.title', { + defaultMessage: 'Elastic managed web crawlers', + })} +

+
+
+ + Coming soon + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.elasticManagedWebCrawlerEmpty.description', + { + defaultMessage: + "We're actively developing Elastic managed web crawlers, that won't require any self-managed infrastructure. You'll be able to handle all configuration in the UI. This will simplify syncing your data into a serverless Elasticsearch project. This new workflow will have two steps:", + } + )} +

+
+
+ + + + + + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.elasticManagedWebCrawlerEmpty.guideOneDescription', + { + defaultMessage: 'Set one or more domain URLs you want to crawl', + } + )} +

+
+
+
+
+ + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.elasticManagedWebCrawlerEmpty.guideThreeDescription', + { + defaultMessage: + 'Configure all the web crawler process using Kibana', + } + )} +

+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/web_crawlers/empty_web_crawlers_prompt.tsx b/x-pack/plugins/serverless_search/public/application/components/web_crawlers/empty_web_crawlers_prompt.tsx new file mode 100644 index 0000000000000..8170ed6da3134 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/web_crawlers/empty_web_crawlers_prompt.tsx @@ -0,0 +1,269 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiIcon, + EuiTitle, + EuiText, + EuiLink, + EuiButton, + EuiBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useKibanaServices } from '../../hooks/use_kibana'; +import { useAssetBasePath } from '../../hooks/use_asset_base_path'; + +import { ELASTIC_MANAGED_WEB_CRAWLERS_PATH, BASE_WEB_CRAWLERS_PATH } from '../../constants'; +import { DecorativeHorizontalStepper } from '../common/decorative_horizontal_stepper'; + +export const EmptyWebCrawlersPrompt: React.FC = () => { + const { + application: { navigateToUrl }, + } = useKibanaServices(); + + const assetBasePath = useAssetBasePath(); + const webCrawlersIcon = assetBasePath + '/web_crawlers.svg'; + const githubIcon = assetBasePath + '/github_white.svg'; + + return ( + + + + + + + + + +

+ {i18n.translate('xpack.serverlessSearch.webCrawlersEmpty.title', { + defaultMessage: 'Set up a web crawler', + })} +

+
+
+ + +

+ {i18n.translate('xpack.serverlessSearch.webCrawlersEmpty.description', { + defaultMessage: + "To set up and deploy a web crawler you'll be working between data source, your terminal, and the Kibana UI. The high level process looks like this:", + })} +

+
+
+ + + + + + + + + + + + + + + + + + + + + + +

+ + {i18n.translate( + 'xpack.serverlessSearch.webCrawlersEmpty.sourceLabel', + { defaultMessage: 'source' } + )} + + ), + docker: ( + + {i18n.translate( + 'xpack.serverlessSearch.webCrawlersEmpty.dockerLabel', + { defaultMessage: 'Docker' } + )} + + ), + }} + /> +

+
+
+
+
+ + + + + + + + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.webCrawlersEmpty.guideOneDescription', + { + defaultMessage: 'Set one or more domain URLs you want to crawl', + } + )} +

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.serverlessSearch.webCrawlersEmpty.guideThreeDescription', + { + defaultMessage: + 'Configure your web crawler and connect it to Elasticsearch', + } + )} +

+
+
+
+
+
+
+
+
+ + + + {i18n.translate('xpack.serverlessSearch.webCrawlersEmpty.selfManagedButton', { + defaultMessage: 'Self-managed web crawler', + })} + + + + + + + navigateToUrl( + `${BASE_WEB_CRAWLERS_PATH}/${ELASTIC_MANAGED_WEB_CRAWLERS_PATH}` + ) + } + > + {i18n.translate( + 'xpack.serverlessSearch.webCrawlersEmpty.elasticManagedButton', + { + defaultMessage: 'Elastic managed web crawler', + } + )} + + + + Coming soon + + + + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/web_crawlers_elastic_managed.tsx b/x-pack/plugins/serverless_search/public/application/components/web_crawlers_elastic_managed.tsx new file mode 100644 index 0000000000000..8ac5a0c59dd14 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/web_crawlers_elastic_managed.tsx @@ -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 { EuiLink, EuiPageTemplate, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; + +import { LEARN_MORE_LABEL } from '../../../common/i18n_string'; + +import { useKibanaServices } from '../hooks/use_kibana'; +import { ElasticManagedWebCrawlersCommingSoon } from './web_crawlers/elastic_managed_web_crawler_coming_soon'; + +export const WebCrawlersElasticManaged = () => { + const { console: consolePlugin } = useKibanaServices(); + + const embeddableConsole = useMemo( + () => (consolePlugin?.EmbeddableConsole ? : null), + [consolePlugin] + ); + + return ( + + + +

+ + {LEARN_MORE_LABEL} + + ), + }} + /> +

+
+
+ + + + {embeddableConsole} +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/web_crawlers_overview.tsx b/x-pack/plugins/serverless_search/public/application/components/web_crawlers_overview.tsx new file mode 100644 index 0000000000000..b7e3763539536 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/web_crawlers_overview.tsx @@ -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 { EuiLink, EuiPageTemplate, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; + +import { LEARN_MORE_LABEL } from '../../../common/i18n_string'; + +import { useKibanaServices } from '../hooks/use_kibana'; +import { EmptyWebCrawlersPrompt } from './web_crawlers/empty_web_crawlers_prompt'; + +export const WebCrawlersOverview = () => { + const { console: consolePlugin } = useKibanaServices(); + + const embeddableConsole = useMemo( + () => (consolePlugin?.EmbeddableConsole ? : null), + [consolePlugin] + ); + + return ( + + + +

+ + {LEARN_MORE_LABEL} + + ), + }} + /> +

+
+
+ + + + {embeddableConsole} +
+ ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/components/web_crawlers_router.tsx b/x-pack/plugins/serverless_search/public/application/components/web_crawlers_router.tsx new file mode 100644 index 0000000000000..7e4ae7a5d2657 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/components/web_crawlers_router.tsx @@ -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 { Route, Routes } from '@kbn/shared-ux-router'; +import React from 'react'; +import { WebCrawlersOverview } from './web_crawlers_overview'; +import { WebCrawlersElasticManaged } from './web_crawlers_elastic_managed'; + +export const WebCrawlersRouter: React.FC = () => { + return ( + + + + + + + + + ); +}; diff --git a/x-pack/plugins/serverless_search/public/application/constants.ts b/x-pack/plugins/serverless_search/public/application/constants.ts index 8e8c15638a860..e5dce2a328d05 100644 --- a/x-pack/plugins/serverless_search/public/application/constants.ts +++ b/x-pack/plugins/serverless_search/public/application/constants.ts @@ -12,5 +12,8 @@ export const INDEX_NAME_PLACEHOLDER = 'index_name'; // Paths export const BASE_CONNECTORS_PATH = 'connectors'; +export const BASE_WEB_CRAWLERS_PATH = 'web_crawlers'; export const EDIT_CONNECTOR_PATH = `${BASE_CONNECTORS_PATH}/:id`; +export const ELASTIC_MANAGED_CONNECTOR_PATH = '/elastic_managed'; +export const ELASTIC_MANAGED_WEB_CRAWLERS_PATH = '/elastic_managed'; export const FILE_UPLOAD_PATH = '/app/ml/filedatavisualizer'; diff --git a/x-pack/plugins/serverless_search/public/application/web_crawlers.tsx b/x-pack/plugins/serverless_search/public/application/web_crawlers.tsx new file mode 100644 index 0000000000000..e9a590c6dee57 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/application/web_crawlers.tsx @@ -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 { CoreStart } from '@kbn/core-lifecycle-browser'; + +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { Router } from '@kbn/shared-ux-router'; +import { ServerlessSearchContext } from './hooks/use_kibana'; + +export async function renderApp( + element: HTMLElement, + core: CoreStart, + services: ServerlessSearchContext, + queryClient: QueryClient +) { + const { WebCrawlersRouter } = await import('./components/web_crawlers_router'); + + ReactDOM.render( + + + + + + + + + + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/serverless_search/public/assets/connectors.svg b/x-pack/plugins/serverless_search/public/assets/connectors.svg index 659e9e5494352..53624f84a8a00 100644 --- a/x-pack/plugins/serverless_search/public/assets/connectors.svg +++ b/x-pack/plugins/serverless_search/public/assets/connectors.svg @@ -1 +1,11 @@ - \ No newline at end of file + + + + + + + + + + + diff --git a/x-pack/plugins/serverless_search/public/assets/web_crawlers.svg b/x-pack/plugins/serverless_search/public/assets/web_crawlers.svg new file mode 100644 index 0000000000000..d6e2464c0f003 --- /dev/null +++ b/x-pack/plugins/serverless_search/public/assets/web_crawlers.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/serverless_search/public/navigation_tree.ts b/x-pack/plugins/serverless_search/public/navigation_tree.ts index ae8f41b8b17f8..524888d0d33e6 100644 --- a/x-pack/plugins/serverless_search/public/navigation_tree.ts +++ b/x-pack/plugins/serverless_search/public/navigation_tree.ts @@ -8,7 +8,7 @@ import type { AppDeepLinkId, NavigationTreeDefinition } from '@kbn/core-chrome-browser'; import type { ApplicationStart } from '@kbn/core-application-browser'; import { i18n } from '@kbn/i18n'; -import { CONNECTORS_LABEL } from '../common/i18n_string'; +import { CONNECTORS_LABEL, WEB_CRAWLERS_LABEL } from '../common/i18n_string'; export const navigationTree = ({ isAppRegistered }: ApplicationStart): NavigationTreeDefinition => { function isAvailable(appId: string, content: T): T[] { @@ -54,6 +54,10 @@ export const navigationTree = ({ isAppRegistered }: ApplicationStart): Navigatio title: CONNECTORS_LABEL, link: 'serverlessConnectors', }, + { + title: WEB_CRAWLERS_LABEL, + link: 'serverlessWebCrawlers', + }, ], }, { diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index d097cd1eb3ad4..3c24211d2a520 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -120,6 +120,27 @@ export class ServerlessSearchPlugin }, }); + const webCrawlersTitle = i18n.translate('xpack.serverlessSearch.app.webCrawlers.title', { + defaultMessage: 'Web Crawlers', + }); + + core.application.register({ + id: 'serverlessWebCrawlers', + title: webCrawlersTitle, + appRoute: '/app/web_crawlers', + euiIconType: 'logoElastic', + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + visibleIn: [], + async mount({ element, history }: AppMountParameters) { + const { renderApp } = await import('./application/web_crawlers'); + const [coreStart, services] = await core.getStartServices(); + coreStart.chrome.docTitle.change(webCrawlersTitle); + docLinks.setDocLinks(coreStart.docLinks.links); + + return await renderApp(element, coreStart, { history, ...services }, queryClient); + }, + }); + const { searchIndices } = setupDeps; core.application.register({ id: 'serverlessHomeRedirect', diff --git a/x-pack/plugins/serverless_search/tsconfig.json b/x-pack/plugins/serverless_search/tsconfig.json index 794f146299a0f..854a90fdb5fb5 100644 --- a/x-pack/plugins/serverless_search/tsconfig.json +++ b/x-pack/plugins/serverless_search/tsconfig.json @@ -54,6 +54,7 @@ "@kbn/core-http-server", "@kbn/logging", "@kbn/security-plugin-types-public", + "@kbn/deeplinks-search", "@kbn/core-application-browser", ] } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5a5e006df3f28..cb51a020eeaac 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -43115,8 +43115,6 @@ "xpack.serverlessSearch.connectors.typeLabel": "Type", "xpack.serverlessSearch.connectors.variablesTitle": "Variable pour votre {url}", "xpack.serverlessSearch.connectors.waitingForConnection": "En attente de connexion", - "xpack.serverlessSearch.connectorsEmpty.availableConnectors": "Connecteurs disponibles", - "xpack.serverlessSearch.connectorsEmpty.createConnector": "Créer un connecteur", "xpack.serverlessSearch.connectorsEmpty.description": "La configuration et le déploiement d'un connecteur se passe entre la source de données tierce, votre terminal et l'UI sans serveur d'Elasticsearch. Le processus à haut niveau ressemble à ça :", "xpack.serverlessSearch.connectorsEmpty.dockerLabel": "Docker", "xpack.serverlessSearch.connectorsEmpty.guideOneDescription": "Choisissez une source de données à synchroniser", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 640d5d403d886..b5a0c4a1481df 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -43081,8 +43081,6 @@ "xpack.serverlessSearch.connectors.typeLabel": "型", "xpack.serverlessSearch.connectors.variablesTitle": "{url}の変数", "xpack.serverlessSearch.connectors.waitingForConnection": "接続を待機中", - "xpack.serverlessSearch.connectorsEmpty.availableConnectors": "使用可能なコネクター", - "xpack.serverlessSearch.connectorsEmpty.createConnector": "コネクターを作成", "xpack.serverlessSearch.connectorsEmpty.description": "コネクターを設定およびデプロイするには、サードパーティのデータソース、端末、ElasticsearchサーバーレスUI の間で作業することになります。プロセスの概要は次のとおりです。", "xpack.serverlessSearch.connectorsEmpty.dockerLabel": "Docker", "xpack.serverlessSearch.connectorsEmpty.guideOneDescription": "同期したいデータソースを選択します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 991c23b73faa0..8c21f00ca8228 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -42429,8 +42429,6 @@ "xpack.serverlessSearch.connectors.typeLabel": "类型", "xpack.serverlessSearch.connectors.variablesTitle": "您的 {url} 的变量", "xpack.serverlessSearch.connectors.waitingForConnection": "等待连接", - "xpack.serverlessSearch.connectorsEmpty.availableConnectors": "可用连接器", - "xpack.serverlessSearch.connectorsEmpty.createConnector": "创建连接器", "xpack.serverlessSearch.connectorsEmpty.description": "要设置并部署连接器,您需要在第三方数据源、终端与 Elasticsearch 无服务器 UI 之间开展工作。高级流程类似于这样:", "xpack.serverlessSearch.connectorsEmpty.dockerLabel": "Docker", "xpack.serverlessSearch.connectorsEmpty.guideOneDescription": "选择要同步的数据源", diff --git a/x-pack/test_serverless/functional/page_objects/svl_search_connectors_page.ts b/x-pack/test_serverless/functional/page_objects/svl_search_connectors_page.ts index 615e3397a45ce..78554ea05beda 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_search_connectors_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_search_connectors_page.ts @@ -14,7 +14,7 @@ export function SvlSearchConnectorsPageProvider({ getService }: FtrProviderConte return { connectorConfigurationPage: { async createConnector() { - await testSubjects.click('serverlessSearchConnectorsOverviewCreateConnectorButton'); + await testSubjects.click('serverlessSearchEmptyConnectorsPromptCreateConnectorButton'); await testSubjects.existOrFail('serverlessSearchEditConnectorButton'); await testSubjects.exists('serverlessSearchConnectorLinkElasticsearchRunWithDockerButton'); await testSubjects.exists('serverlessSearchConnectorLinkElasticsearchRunFromSourceButton'); @@ -90,9 +90,9 @@ export function SvlSearchConnectorsPageProvider({ getService }: FtrProviderConte }, async expectConnectorOverviewPageComponentsToExist() { await testSubjects.existOrFail('serverlessSearchConnectorsTitle'); - await testSubjects.existOrFail('serverlessSearchConnectorsOverviewElasticConnectorsLink'); + // await testSubjects.existOrFail('serverlessSearchConnectorsOverviewElasticConnectorsLink'); await testSubjects.exists('serverlessSearchEmptyConnectorsPromptCreateConnectorButton'); - await testSubjects.existOrFail('serverlessSearchConnectorsOverviewCreateConnectorButton'); + // await testSubjects.existOrFail('serverlessSearchConnectorsOverviewCreateConnectorButton'); }, async expectConnectorTableToExist() { await testSubjects.existOrFail('serverlessSearchConnectorTable'); diff --git a/x-pack/test_serverless/functional/test_suites/search/navigation.ts b/x-pack/test_serverless/functional/test_suites/search/navigation.ts index 97952d68f8fd1..24009866b2b15 100644 --- a/x-pack/test_serverless/functional/test_suites/search/navigation.ts +++ b/x-pack/test_serverless/functional/test_suites/search/navigation.ts @@ -241,6 +241,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await solutionNavigation.sidenav.expectLinkExists({ text: 'Data' }); await solutionNavigation.sidenav.expectLinkExists({ text: 'Index Management' }); await solutionNavigation.sidenav.expectLinkExists({ text: 'Connectors' }); + await solutionNavigation.sidenav.expectLinkExists({ text: 'Web crawlers' }); await solutionNavigation.sidenav.expectLinkExists({ text: 'Build' }); await solutionNavigation.sidenav.expectLinkExists({ text: 'Dev Tools' }); await solutionNavigation.sidenav.expectLinkExists({ text: 'Playground' }); @@ -265,6 +266,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { 'data', 'management:index_management', 'serverlessConnectors', + 'serverlessWebCrawlers', 'build', 'dev_tools', 'searchPlayground', From d27838795681817657508c78422732f0dbe8dd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Georgiana-Andreea=20Onolea=C8=9B=C4=83?= Date: Tue, 19 Nov 2024 14:46:39 +0200 Subject: [PATCH 16/61] [ResponseOps][Connectors] Optional field blocks editing of case Webhook connector (#198314) Closes https://github.com/elastic/kibana/issues/191915 ## Summary When editing an existing Webhook - Case Management connector clicking Save will fail if the optional step 4 "Add a comment in case" is not populated - added extra checks for null, undefined or empty string: returns "undefined" immediately, skipping any further validation - this ensures that the "comment url" and "comment object" fields can remain empty without triggering a validation error (previously, if the fields were empty, the value passed was null). - the header of the add comment section was changed to a toggle element, when it's on, all the elements in the add comment section are visible https://github.com/user-attachments/assets/49839173-e99b-4ee8-be6d-b682ad0b8a80 --------- Co-authored-by: Elastic Machine --- .../cases_webhook/steps/update.tsx | 432 ++++++++++-------- .../cases_webhook/translations.ts | 21 +- .../cases_webhook/validator.ts | 32 +- .../cases_webhook/webhook_connectors.test.tsx | 43 ++ 4 files changed, 332 insertions(+), 196 deletions(-) diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/update.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/update.tsx index e7a37d415f4af..dba4f13ec9c86 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/update.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/update.tsx @@ -5,13 +5,22 @@ * 2.0. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useState, useMemo } from 'react'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; -import { FIELD_TYPES, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiSwitch } from '@elastic/eui'; +import { + FIELD_TYPES, + UseField, + useFormContext, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { JsonFieldWrapper, MustacheTextFieldWrapper } from '@kbn/triggers-actions-ui-plugin/public'; -import { containsCommentsOrEmpty, containsTitleAndDesc, isUrlButCanBeEmpty } from '../validator'; +import { + containsCommentsOrEmpty, + containsTitleAndDesc, + isUrlButCanBeEmpty, + validateCreateComment, +} from '../validator'; import { casesVars, commentVars, urlVars } from '../action_variables'; import { HTTP_VERBS } from '../webhook_connectors'; import { styles } from './update.styles'; @@ -23,185 +32,238 @@ interface Props { readOnly: boolean; } -export const UpdateStep: FunctionComponent = ({ display, readOnly }) => ( - - -

{i18n.STEP_4A}

- -

{i18n.STEP_4A_DESCRIPTION}

-
-
- - - - ({ text: verb.toUpperCase(), value: verb })), - readOnly, - }, - }} - /> - - - - - - - - - - - - -

{i18n.STEP_4B}

- -

{i18n.STEP_4B_DESCRIPTION}

-
-
- - - - ({ text: verb.toUpperCase(), value: verb })), - readOnly, - }, - }} - /> - - - - - - - - = ({ display, readOnly }) => { + const { getFieldDefaultValue } = useFormContext(); + + const hasCommentDefaultValue = + !!getFieldDefaultValue('config.createCommentUrl') || + !!getFieldDefaultValue('config.createCommentJson'); + + const [isAddCommentToggled, setIsAddCommentToggled] = useState(Boolean(hasCommentDefaultValue)); + + const onAddCommentToggle = () => { + setIsAddCommentToggled((prev) => !prev); + }; + + const updateIncidentMethodConfig = useMemo( + () => ({ + label: i18n.UPDATE_INCIDENT_METHOD, + defaultValue: 'put', + type: FIELD_TYPES.SELECT, + validations: [{ validator: emptyField(i18n.UPDATE_METHOD_REQUIRED) }], + }), + [] + ); + + const updateIncidentUrlConfig = useMemo( + () => ({ + label: i18n.UPDATE_INCIDENT_URL, + validations: [{ validator: urlField(i18n.UPDATE_URL_REQUIRED) }], + helpText: i18n.UPDATE_INCIDENT_URL_HELP, + }), + [] + ); + + const updateIncidentJsonConfig = useMemo( + () => ({ + label: i18n.UPDATE_INCIDENT_JSON, + helpText: i18n.UPDATE_INCIDENT_JSON_HELP, + validations: [ + { validator: emptyField(i18n.UPDATE_INCIDENT_REQUIRED) }, + { validator: containsTitleAndDesc() }, + ], + }), + [] + ); + + const createCommentMethodConfig = useMemo( + () => ({ + label: i18n.CREATE_COMMENT_METHOD, + defaultValue: 'put', + type: FIELD_TYPES.SELECT, + validations: [{ validator: emptyField(i18n.CREATE_COMMENT_METHOD_REQUIRED) }], + }), + [] + ); + + const createCommentUrlConfig = useMemo( + () => ({ + label: i18n.CREATE_COMMENT_URL, + fieldsToValidateOnChange: ['config.createCommentUrl', 'config.createCommentJson'], + validations: [ + { validator: isUrlButCanBeEmpty(i18n.CREATE_COMMENT_URL_FORMAT_REQUIRED) }, + { + validator: validateCreateComment( + i18n.CREATE_COMMENT_URL_MISSING, + 'config.createCommentJson' + ), + }, + ], + helpText: i18n.CREATE_COMMENT_URL_HELP, + }), + [] + ); + + const createCommentJsonConfig = useMemo( + () => ({ + label: i18n.CREATE_COMMENT_JSON, + helpText: i18n.CREATE_COMMENT_JSON_HELP, + fieldsToValidateOnChange: ['config.createCommentJson', 'config.createCommentUrl'], + validations: [ + { validator: containsCommentsOrEmpty(i18n.CREATE_COMMENT_FORMAT_MESSAGE) }, + { + validator: validateCreateComment( + i18n.CREATE_COMMENT_JSON_MISSING, + 'config.createCommentUrl' + ), + }, + ], + }), + [] + ); + + return ( + <> + + +

{i18n.STEP_4A}

+ +

{i18n.STEP_4A_DESCRIPTION}

+
+
+ + + + ({ text: verb.toUpperCase(), value: verb })), + readOnly, + }, + }} + /> + + + + + + + + + + + + -
-
-
-); + {isAddCommentToggled && ( + <> + + + +

{i18n.STEP_4B_DESCRIPTION}

+
+
+ + + ({ + text: verb.toUpperCase(), + value: verb, + })), + readOnly, + }, + }} + /> + + + + + + + + + + + + )} + + + ); +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts index 8c44b6197ef9c..5653fe4adc851 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts @@ -54,13 +54,28 @@ export const UPDATE_METHOD_REQUIRED = i18n.translate( } ); -export const CREATE_COMMENT_URL_REQUIRED = i18n.translate( +export const CREATE_COMMENT_URL_FORMAT_REQUIRED = i18n.translate( 'xpack.stackConnectors.components.casesWebhook.error.requiredCreateCommentUrlText', { defaultMessage: 'Create comment URL must be URL format.', } ); -export const CREATE_COMMENT_MESSAGE = i18n.translate( + +export const CREATE_COMMENT_URL_MISSING = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.error.requiredCreateCommentUrlMissing', + { + defaultMessage: 'Create comment URL is required.', + } +); + +export const CREATE_COMMENT_JSON_MISSING = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.error.requiredCreateCommentJsonMissing', + { + defaultMessage: 'Create comment Json is required.', + } +); + +export const CREATE_COMMENT_FORMAT_MESSAGE = i18n.translate( 'xpack.stackConnectors.components.casesWebhook.error.requiredCreateCommentIncidentText', { defaultMessage: 'Create comment object must be valid JSON.', @@ -373,7 +388,7 @@ export const STEP_4A_DESCRIPTION = i18n.translate( ); export const STEP_4B = i18n.translate('xpack.stackConnectors.components.casesWebhook.step4b', { - defaultMessage: 'Add comment in case (optional)', + defaultMessage: 'Add comment in case', }); export const STEP_4B_DESCRIPTION = i18n.translate( diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts index d972c9bbd1f86..8c64042801635 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts @@ -100,6 +100,11 @@ export const containsCommentsOrEmpty = (message: string) => (...args: Parameters): ReturnType> => { const [{ value, path }] = args; + + if (value === null || value === undefined || value === '') { + return undefined; + } + if (typeof value !== 'string') { return { code: 'ERR_FIELD_FORMAT', @@ -107,9 +112,6 @@ export const containsCommentsOrEmpty = message, }; } - if (value.length === 0) { - return undefined; - } const comment = templateActionVariable( commentVars.find((actionVariable) => actionVariable.name === 'case.comment')! @@ -128,16 +130,30 @@ export const isUrlButCanBeEmpty = (message: string) => (...args: Parameters) => { const [{ value }] = args; + const error: ValidationError = { code: 'ERR_FIELD_FORMAT', formatType: 'URL', message, }; - if (typeof value !== 'string') { - return error; - } - if (value.length === 0) { + + if (value === null || value === undefined || value === '') { return undefined; } - return isUrl(value) ? undefined : error; + return typeof value === 'string' && isUrl(value) ? undefined : error; + }; + +export const validateCreateComment = + (message: string, fieldName: string) => + (...args: Parameters) => { + const [{ value, formData }] = args; + const otherFielValue = formData[fieldName]; + + const error: ValidationError = { + code: 'ERR_FIELD_FORMAT', + formatType: 'STRING', + message, + }; + + return !value && otherFielValue ? error : undefined; }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx index 713f2bd9e6f83..911875f31eb26 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx @@ -97,6 +97,49 @@ describe('CasesWebhookActionConnectorFields renders', () => { expect(await screen.findByTestId('webhookCreateCommentJson')).toBeInTheDocument(); }); + it('Add comment to case section is rendered only when the toggle button is on', async () => { + const incompleteActionConnector = { + ...actionConnector, + config: { + ...actionConnector.config, + createCommentUrl: undefined, + createCommentJson: undefined, + }, + }; + render( + + {}} + /> + + ); + + await userEvent.click(await screen.findByTestId('webhookAddCommentToggle')); + + expect(await screen.findByTestId('webhookCreateCommentMethodSelect')).toBeInTheDocument(); + expect(await screen.findByTestId('createCommentUrlInput')).toBeInTheDocument(); + expect(await screen.findByTestId('webhookCreateCommentJson')).toBeInTheDocument(); + }); + + it('Toggle button is active when create comment section fields are populated', async () => { + render( + + {}} + /> + + ); + + expect(await screen.findByTestId('webhookAddCommentToggle')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + it('connector auth toggles work as expected', async () => { render( From 5d77a1a67c5eb243b14ea0613e411cc13ec73406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Georgiana-Andreea=20Onolea=C8=9B=C4=83?= Date: Tue, 19 Nov 2024 14:48:30 +0200 Subject: [PATCH 17/61] [ResponseOps][Connectors]Possible to open close incident in ServiceNow (#199989) Closes https://github.com/elastic/kibana/issues/184646 ## Summary - updated the code to include additional validation, ensuring that updateIncident({...}) is not called when incidentToBeClosed object is empty, this ensured that cases where getIncident or getIncidentByCorrelationId return an empty object are properly handled. - small change in Run connector flyout > configuration tab: fixed typo, "read-only" instead of "readonly" --- .../lib/servicenow/service.test.ts | 17 +++++++++++++++++ .../connector_types/lib/servicenow/service.ts | 3 ++- .../edit_connector_flyout/index.test.tsx | 4 ++-- .../edit_connector_flyout/read_only.test.tsx | 2 +- .../edit_connector_flyout/read_only.tsx | 2 +- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts index aa8d248566d9a..5f2f5ee019a5f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts @@ -1232,6 +1232,23 @@ describe('ServiceNow service', () => { `); }); + test('it should return null if no incident found, when incident to be closed is null', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + result: [], + }, + })); + + const res = await service.closeIncident({ incidentId: '2', correlationId: null }); + expect(logger.warn.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "[ServiceNow][CloseIncident] No incident found with correlation_id: null or incidentId: 2.", + ] + `); + + expect(res).toBeNull(); + }); + test('it should return null if found incident with correlation id is null', async () => { requestMock.mockImplementationOnce(() => ({ data: { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts index 84a8592aaa832..4cfe1ad56cfa7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts @@ -8,6 +8,7 @@ import { AxiosResponse } from 'axios'; import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { isEmpty } from 'lodash'; import { ExternalService, ExternalServiceParamsCreate, @@ -306,7 +307,7 @@ export const createExternalService: ServiceFactory = ({ incidentToBeClosed = await getIncidentByCorrelationId(correlationId); } - if (incidentToBeClosed === null) { + if (incidentToBeClosed === null || isEmpty(incidentToBeClosed)) { logger.warn( `[ServiceNow][CloseIncident] No incident found with correlation_id: ${correlationId} or incidentId: ${incidentId}.` ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx index 1ff0d9f679a05..6ad74732844c0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx @@ -143,7 +143,7 @@ describe('EditConnectorFlyout', () => { }); await waitFor(() => { - expect(queryByText('This connector is readonly.')).not.toBeInTheDocument(); + expect(queryByText('This connector is read-only.')).not.toBeInTheDocument(); expect(getByTestId('nameInput')).toHaveValue('My test'); expect(getByTestId('test-connector-text-field')).toHaveValue('My text field'); }); @@ -176,7 +176,7 @@ describe('EditConnectorFlyout', () => { /> ); - expect(getByText('This connector is readonly.')).toBeInTheDocument(); + expect(getByText('This connector is read-only.')).toBeInTheDocument(); }); it('shows the buttons', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.test.tsx index baa8eed5265d5..194a3bf1f1524 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.test.tsx @@ -25,7 +25,7 @@ describe('ReadOnlyConnectorMessage', () => { { wrapper: I18nProvider } ); - expect(getByText('This connector is readonly.')).toBeInTheDocument(); + expect(getByText('This connector is read-only.')).toBeInTheDocument(); expect(getByTestId('read-only-link')).toHaveProperty('href', 'https://example.com/'); expect(queryByText('Extra Component')).toBeNull(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.tsx index f32bc2a34bd6b..354f832090869 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/read_only.tsx @@ -22,7 +22,7 @@ export const ReadOnlyConnectorMessage: React.FC<{ <> {i18n.translate('xpack.triggersActionsUI.sections.editConnectorForm.descriptionText', { - defaultMessage: 'This connector is readonly.', + defaultMessage: 'This connector is read-only.', })} From 6e9520aca268c413b2e2264830791d68dbf7dcc9 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 20 Nov 2024 00:02:43 +1100 Subject: [PATCH 18/61] Authorized route migration for routes owned by security-detection-rule-management (#198383) ### Authz API migration for authorized routes This PR migrates `access:` tags used in route definitions to new security configuration. Please refer to the documentation for more information: [Authorization API](https://docs.elastic.dev/kibana-dev-docs/key-concepts/security-api-authorization) ### **Before migration:** Access control tags were defined in the `options` object of the route: ```ts router.get({ path: '/api/path', options: { tags: ['access:', 'access:'], }, ... }, handler); ``` ### **After migration:** Tags have been replaced with the more robust `security.authz.requiredPrivileges` field under `security`: ```ts router.get({ path: '/api/path', security: { authz: { requiredPrivileges: ['', ''], }, }, ... }, handler); ``` ### What to do next? 1. Review the changes in this PR. 2. You might need to update your tests to reflect the new security configuration: - If you have tests that rely on checking `access` tags. - If you have snapshot tests that include the route definition. - If you have FTR tests that rely on checking unauthorized error message. The error message changed to also include missing privileges. ## Any questions? If you have any questions or need help with API authorization, please reach out to the `@elastic/kibana-security` team. Co-authored-by: Maxim Palenov Co-authored-by: Elastic Machine Co-authored-by: Larry Gregory --- .../api/get_all_integrations/route.ts | 6 ++++-- .../api/get_installed_integrations/route.ts | 6 ++++-- .../bootstrap_prebuilt_rules.ts | 6 ++++-- .../get_prebuilt_rules_and_timelines_status_route.ts | 6 ++++-- .../get_prebuilt_rules_status_route.ts | 6 ++++-- .../install_prebuilt_rules_and_timelines_route.ts | 6 +++++- .../perform_rule_installation_route.ts | 6 +++++- .../perform_rule_upgrade_route.ts | 6 +++++- .../review_rule_installation_route.ts | 6 +++++- .../review_rule_upgrade/review_rule_upgrade_route.ts | 6 +++++- .../rule_management/api/rules/bulk_actions/route.ts | 7 ++++++- .../api/rules/bulk_create_rules/route.ts | 6 +++++- .../api/rules/bulk_patch_rules/route.ts | 6 +++++- .../api/rules/bulk_update_rules/route.ts | 6 +++++- .../api/rules/coverage_overview/route.ts | 6 ++++-- .../rule_management/api/rules/create_rule/route.ts | 6 ++++-- .../rule_management/api/rules/delete_rule/route.ts | 6 ++++-- .../rule_management/api/rules/export_rules/route.ts | 6 +++++- .../rule_management/api/rules/filters/route.ts | 6 ++++-- .../rule_management/api/rules/find_rules/route.ts | 6 ++++-- .../rule_management/api/rules/import_rules/route.ts | 6 +++++- .../rule_management/api/rules/patch_rule/route.ts | 6 ++++-- .../rule_management/api/rules/read_rule/route.ts | 6 ++++-- .../rule_management/api/rules/update_rule/route.ts | 6 ++++-- .../rule_management/api/tags/read_tags/route.ts | 6 ++++-- .../get_cluster_health/get_cluster_health_route.ts | 12 ++++++++---- .../get_rule_health/get_rule_health_route.ts | 6 ++++-- .../get_space_health/get_space_health_route.ts | 12 ++++++++---- .../setup/setup_health_route.ts | 6 ++++-- .../get_rule_execution_events_route.ts | 6 ++++-- .../get_rule_execution_results_route.ts | 6 ++++-- 31 files changed, 144 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts index 5b4eab27f71ab..4b5642b9d199b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts @@ -23,8 +23,10 @@ export const getAllIntegrationsRoute = (router: SecuritySolutionPluginRouter) => .get({ access: 'internal', path: GET_ALL_INTEGRATIONS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts index 3a3d159d1337f..27b1c4b103ab7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts @@ -21,8 +21,10 @@ export const getInstalledIntegrationsRoute = (router: SecuritySolutionPluginRout .get({ access: 'internal', path: GET_INSTALLED_INTEGRATIONS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/bootstrap_prebuilt_rules/bootstrap_prebuilt_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/bootstrap_prebuilt_rules/bootstrap_prebuilt_rules.ts index d17435a543320..8d3788a2cf7f8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/bootstrap_prebuilt_rules/bootstrap_prebuilt_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/bootstrap_prebuilt_rules/bootstrap_prebuilt_rules.ts @@ -21,8 +21,10 @@ export const bootstrapPrebuiltRulesRoute = (router: SecuritySolutionPluginRouter .post({ access: 'internal', path: BOOTSTRAP_PREBUILT_RULES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts index 3713176e919c5..dc9c15ac5b5f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts @@ -30,8 +30,10 @@ export const getPrebuiltRulesAndTimelinesStatusRoute = (router: SecuritySolution .get({ access: 'public', path: PREBUILT_RULES_STATUS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts index 86809a3a79a93..0561c826e0c78 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts @@ -20,8 +20,10 @@ export const getPrebuiltRulesStatusRoute = (router: SecuritySolutionPluginRouter .get({ access: 'internal', path: GET_PREBUILT_RULES_STATUS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts index 11e841ed50431..8740d27fce817 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts @@ -33,8 +33,12 @@ export const installPrebuiltRulesAndTimelinesRoute = (router: SecuritySolutionPl .put({ access: 'public', path: PREBUILT_RULES_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts index 1a29568ca496b..8b4d38bd2f4a4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts @@ -34,8 +34,12 @@ export const performRuleInstallationRoute = (router: SecuritySolutionPluginRoute .post({ access: 'internal', path: PERFORM_RULE_INSTALLATION_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts index c8b5d459f6787..db5f7a186d303 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts @@ -35,8 +35,12 @@ export const performRuleUpgradeRoute = ( .post({ access: 'internal', path: PERFORM_RULE_UPGRADE_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts index 00fc5e2beb5b8..c1c45532f61bb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts @@ -26,8 +26,12 @@ export const reviewRuleInstallationRoute = (router: SecuritySolutionPluginRouter .post({ access: 'internal', path: REVIEW_RULE_INSTALLATION_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts index 382ec27a1bf35..3da62bd9bb21d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts @@ -35,8 +35,12 @@ export const reviewRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => .post({ access: 'internal', path: REVIEW_RULE_UPGRADE_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 658a9b193e0a2..e599ff4a936ef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -61,8 +61,13 @@ export const performBulkActionRoute = ( .post({ access: 'public', path: DETECTION_ENGINE_RULES_BULK_ACTION, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution', routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], + tags: [routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], timeout: { idleSocket: RULE_MANAGEMENT_BULK_ACTION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_create_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_create_rules/route.ts index 9a3751bfb1d04..225782ef01942 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_create_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_create_rules/route.ts @@ -39,8 +39,12 @@ export const bulkCreateRulesRoute = (router: SecuritySolutionPluginRouter, logge .post({ access: 'public', path: DETECTION_ENGINE_RULES_BULK_CREATE, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: RULE_MANAGEMENT_BULK_ACTION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts index fc5edf0e65ac3..5bd8adaf86a38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts @@ -33,8 +33,12 @@ export const bulkPatchRulesRoute = (router: SecuritySolutionPluginRouter, logger .patch({ access: 'public', path: DETECTION_ENGINE_RULES_BULK_UPDATE, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: RULE_MANAGEMENT_BULK_ACTION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_update_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_update_rules/route.ts index cccd1656d5091..d2c985fbc70e6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_update_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_update_rules/route.ts @@ -37,8 +37,12 @@ export const bulkUpdateRulesRoute = (router: SecuritySolutionPluginRouter, logge .put({ access: 'public', path: DETECTION_ENGINE_RULES_BULK_UPDATE, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: RULE_MANAGEMENT_BULK_ACTION_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/route.ts index 0a298008dd354..a959c522c1718 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/route.ts @@ -22,8 +22,10 @@ export const getCoverageOverviewRoute = (router: SecuritySolutionPluginRouter) = .post({ access: 'internal', path: RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.ts index aa6425b2e673c..a5fee66d00148 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.ts @@ -27,8 +27,10 @@ export const createRuleRoute = (router: SecuritySolutionPluginRouter): void => { access: 'public', path: DETECTION_ENGINE_RULES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/delete_rule/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/delete_rule/route.ts index 42a6a1c47544f..a5854e9a2caf5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/delete_rule/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/delete_rule/route.ts @@ -24,8 +24,10 @@ export const deleteRuleRoute = (router: SecuritySolutionPluginRouter) => { .delete({ access: 'public', path: DETECTION_ENGINE_RULES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts index 3c770c714334c..a37bb29963332 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts @@ -33,8 +33,12 @@ export const exportRulesRoute = ( .post({ access: 'public', path: `${DETECTION_ENGINE_RULES_URL}/_export`, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], timeout: { idleSocket: RULE_MANAGEMENT_IMPORT_EXPORT_SOCKET_TIMEOUT_MS, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/filters/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/filters/route.ts index 183d4f8e2f78d..05633892cdddb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/filters/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/filters/route.ts @@ -56,8 +56,10 @@ export const getRuleManagementFilters = (router: SecuritySolutionPluginRouter) = .get({ access: 'internal', path: RULE_MANAGEMENT_FILTERS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts index 02ff637ab6f10..899e568e79630 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts @@ -25,8 +25,10 @@ export const findRulesRoute = (router: SecuritySolutionPluginRouter, logger: Log .get({ access: 'public', path: DETECTION_ENGINE_RULES_URL_FIND, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts index e9131050d9629..d6a5213fcbea6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts @@ -47,8 +47,12 @@ export const importRulesRoute = (router: SecuritySolutionPluginRouter, config: C .post({ access: 'public', path: `${DETECTION_ENGINE_RULES_URL}/_import`, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], body: { maxBytes: config.maxRuleImportPayloadBytes, output: 'stream', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts index 3886f63c482b0..fcd388e81d1e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts @@ -26,8 +26,10 @@ export const patchRuleRoute = (router: SecuritySolutionPluginRouter) => { .patch({ access: 'public', path: DETECTION_ENGINE_RULES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/read_rule/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/read_rule/route.ts index a119d1afae912..f8826e8aad45b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/read_rule/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/read_rule/route.ts @@ -24,8 +24,10 @@ export const readRuleRoute = (router: SecuritySolutionPluginRouter, logger: Logg .get({ access: 'public', path: DETECTION_ENGINE_RULES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.ts index fb7a7a9e3197d..0bedcb25de528 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.ts @@ -27,8 +27,10 @@ export const updateRuleRoute = (router: SecuritySolutionPluginRouter) => { .put({ access: 'public', path: DETECTION_ENGINE_RULES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts index 5120603f9f674..d94f695f39179 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts @@ -18,8 +18,10 @@ export const readTagsRoute = (router: SecuritySolutionPluginRouter) => { .get({ access: 'public', path: DETECTION_ENGINE_TAGS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_cluster_health/get_cluster_health_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_cluster_health/get_cluster_health_route.ts index 719f46788a524..d6d9e6843e5a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_cluster_health/get_cluster_health_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_cluster_health/get_cluster_health_route.ts @@ -36,8 +36,10 @@ export const getClusterHealthRoute = (router: SecuritySolutionPluginRouter) => { .get({ access: 'internal', path: GET_CLUSTER_HEALTH_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( @@ -62,8 +64,10 @@ export const getClusterHealthRoute = (router: SecuritySolutionPluginRouter) => { .post({ access: 'internal', path: GET_CLUSTER_HEALTH_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_rule_health/get_rule_health_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_rule_health/get_rule_health_route.ts index a69f7961b19f8..401040b33faa5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_rule_health/get_rule_health_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_rule_health/get_rule_health_route.ts @@ -33,8 +33,10 @@ export const getRuleHealthRoute = (router: SecuritySolutionPluginRouter) => { .post({ access: 'internal', path: GET_RULE_HEALTH_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_space_health/get_space_health_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_space_health/get_space_health_route.ts index 96ced4e34151d..772de5aead760 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_space_health/get_space_health_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/get_space_health/get_space_health_route.ts @@ -36,8 +36,10 @@ export const getSpaceHealthRoute = (router: SecuritySolutionPluginRouter) => { .get({ access: 'internal', path: GET_SPACE_HEALTH_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( @@ -62,8 +64,10 @@ export const getSpaceHealthRoute = (router: SecuritySolutionPluginRouter) => { .post({ access: 'internal', path: GET_SPACE_HEALTH_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/setup/setup_health_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/setup/setup_health_route.ts index 685ce8f677952..0e8e5e5b676fa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/setup/setup_health_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/detection_engine_health/setup/setup_health_route.ts @@ -22,8 +22,10 @@ export const setupHealthRoute = (router: SecuritySolutionPluginRouter) => { .post({ access: 'internal', path: SETUP_HEALTH_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.ts index 4e8001193b5c5..fc3c485710c1a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.ts @@ -27,8 +27,10 @@ export const getRuleExecutionEventsRoute = (router: SecuritySolutionPluginRouter .get({ access: 'internal', path: GET_RULE_EXECUTION_EVENTS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts index bf3a9864260ac..c23396e139afc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts @@ -27,8 +27,10 @@ export const getRuleExecutionResultsRoute = (router: SecuritySolutionPluginRoute .get({ access: 'internal', path: GET_RULE_EXECUTION_RESULTS_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( From 3757e641278a5186919e35a0f980ac3cda7e8ccd Mon Sep 17 00:00:00 2001 From: Tiago Vila Verde Date: Tue, 19 Nov 2024 14:11:24 +0100 Subject: [PATCH 19/61] [Entity Analytics] [Entity Store] Refactor entity store enablement (server side) (#199638) ## Summary This PR adds 2 new endpoints regarding enablement of the Entity Store: * `api/entity_store/enable`, which initializes entity engines for both `user` and `host` entities * `api/entity_store/status`, which computes a global store status based on the individual engine status In addition, running initialization of multiple engines in parallel is now allowed. ### How to test 1. Use dev tools to call `POST kbn:/api/entity_store/enable` 2. Check that two engines were created and that the status is `installing` by calling `GET kbn:/api/entity_store/status` 3. Wait a few seconds and keep calling the `status` endpoint. Once initialization finishes, the status should switch to `running` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- oas_docs/output/kibana.serverless.yaml | 66 +++++++++++++++++ oas_docs/output/kibana.yaml | 64 +++++++++++++++++ .../entity_store/common.gen.ts | 5 ++ .../entity_store/common.schema.yaml | 9 +++ .../entity_store/enablement.gen.ts | 42 +++++++++++ .../entity_store/enablement.schema.yaml | 64 +++++++++++++++++ .../common/api/quickstart_client.gen.ts | 33 +++++++++ ...alytics_api_2023_10_31.bundled.schema.yaml | 64 +++++++++++++++++ ...alytics_api_2023_10_31.bundled.schema.yaml | 64 +++++++++++++++++ .../entity_store/constants.ts | 10 ++- .../entity_store/entity_store_data_client.ts | 58 ++++++++++++++- .../entity_store/routes/enablement.ts | 67 ++++++++++++++++++ .../routes/register_entity_store_routes.ts | 4 ++ .../entity_store/routes/status.ts | 70 +++++++++++++++++++ .../services/security_solution_api.gen.ts | 19 +++++ .../{engine.ts => entity_store.ts} | 50 ++++++++++++- ...s.ts => entity_store_nondefault_spaces.ts} | 0 .../trial_license_complete_tier/index.ts | 4 +- .../entity_analytics/utils/entity_store.ts | 11 +++ 19 files changed, 697 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts rename x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/{engine.ts => entity_store.ts} (78%) rename x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/{engine_nondefault_spaces.ts => entity_store_nondefault_spaces.ts} (100%) diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 55dd5277d1d93..4b35e4d9c78fc 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -7395,6 +7395,43 @@ paths: tags: - Security Endpoint Management API x-beta: true + /api/entity_store/enable: + post: + operationId: InitEntityStore + requestBody: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + fieldHistoryLength: + default: 10 + description: The number of historical values to keep for each field. + type: integer + filter: + type: string + indexPattern: + $ref: '#/components/schemas/Security_Entity_Analytics_API_IndexPattern' + description: Schema for the entity store initialization + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + type: array + succeeded: + type: boolean + description: Successful response + summary: Initialize the Entity Store + tags: + - Security Entity Analytics API + x-beta: true /api/entity_store/engines: get: operationId: ListEntityEngines @@ -7713,6 +7750,27 @@ paths: tags: - Security Entity Analytics API x-beta: true + /api/entity_store/status: + get: + operationId: GetEntityStoreStatus + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + type: array + status: + $ref: '#/components/schemas/Security_Entity_Analytics_API_StoreStatus' + description: Successful response + summary: Get the status of the Entity Store + tags: + - Security Entity Analytics API + x-beta: true /api/exception_lists: delete: description: Delete an exception list using the `id` or `list_id` field. @@ -45880,6 +45938,14 @@ components: - index - description - category + Security_Entity_Analytics_API_StoreStatus: + enum: + - not_installed + - installing + - running + - stopped + - error + type: string Security_Entity_Analytics_API_TaskManagerUnavailableResponse: description: Task manager is unavailable type: object diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index b2c3ae00be9d0..94e987510c649 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -10282,6 +10282,42 @@ paths: summary: Create or update a protection updates note tags: - Security Endpoint Management API + /api/entity_store/enable: + post: + operationId: InitEntityStore + requestBody: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + fieldHistoryLength: + default: 10 + description: The number of historical values to keep for each field. + type: integer + filter: + type: string + indexPattern: + $ref: '#/components/schemas/Security_Entity_Analytics_API_IndexPattern' + description: Schema for the entity store initialization + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + type: array + succeeded: + type: boolean + description: Successful response + summary: Initialize the Entity Store + tags: + - Security Entity Analytics API /api/entity_store/engines: get: operationId: ListEntityEngines @@ -10591,6 +10627,26 @@ paths: summary: List Entity Store Entities tags: - Security Entity Analytics API + /api/entity_store/status: + get: + operationId: GetEntityStoreStatus + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + type: array + status: + $ref: '#/components/schemas/Security_Entity_Analytics_API_StoreStatus' + description: Successful response + summary: Get the status of the Entity Store + tags: + - Security Entity Analytics API /api/exception_lists: delete: description: Delete an exception list using the `id` or `list_id` field. @@ -53601,6 +53657,14 @@ components: - index - description - category + Security_Entity_Analytics_API_StoreStatus: + enum: + - not_installed + - installing + - running + - stopped + - error + type: string Security_Entity_Analytics_API_TaskManagerUnavailableResponse: description: Task manager is unavailable type: object diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts index 228bf1e515675..7e419dbe6453c 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts @@ -39,6 +39,11 @@ export const EngineDescriptor = z.object({ error: z.object({}).optional(), }); +export type StoreStatus = z.infer; +export const StoreStatus = z.enum(['not_installed', 'installing', 'running', 'stopped', 'error']); +export type StoreStatusEnum = typeof StoreStatus.enum; +export const StoreStatusEnum = StoreStatus.enum; + export type InspectQuery = z.infer; export const InspectQuery = z.object({ response: z.array(z.string()), diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml index 00b100516b76c..9a42191a556ac 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml @@ -41,6 +41,15 @@ components: - stopped - updating - error + + StoreStatus: + type: string + enum: + - not_installed + - installing + - running + - stopped + - error IndexPattern: type: string diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts new file mode 100644 index 0000000000000..9644a1a333d16 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts @@ -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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Enable Entity Store + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { IndexPattern, EngineDescriptor, StoreStatus } from './common.gen'; + +export type GetEntityStoreStatusResponse = z.infer; +export const GetEntityStoreStatusResponse = z.object({ + status: StoreStatus.optional(), + engines: z.array(EngineDescriptor).optional(), +}); + +export type InitEntityStoreRequestBody = z.infer; +export const InitEntityStoreRequestBody = z.object({ + /** + * The number of historical values to keep for each field. + */ + fieldHistoryLength: z.number().int().optional().default(10), + indexPattern: IndexPattern.optional(), + filter: z.string().optional(), +}); +export type InitEntityStoreRequestBodyInput = z.input; + +export type InitEntityStoreResponse = z.infer; +export const InitEntityStoreResponse = z.object({ + succeeded: z.boolean().optional(), + engines: z.array(EngineDescriptor).optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml new file mode 100644 index 0000000000000..306e876dfc4a7 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.0 + +info: + title: Enable Entity Store + version: '2023-10-31' +paths: + /api/entity_store/enable: + post: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: InitEntityStore + summary: Initialize the Entity Store + + requestBody: + description: Schema for the entity store initialization + required: true + content: + application/json: + schema: + type: object + properties: + fieldHistoryLength: + type: integer + description: The number of historical values to keep for each field. + default: 10 + indexPattern: + $ref: './common.schema.yaml#/components/schemas/IndexPattern' + filter: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + succeeded: + type: boolean + engines: + type: array + items: + $ref: './common.schema.yaml#/components/schemas/EngineDescriptor' + + /api/entity_store/status: + get: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: GetEntityStoreStatus + summary: Get the status of the Entity Store + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + status: + $ref: './common.schema.yaml#/components/schemas/StoreStatus' + engines: + type: array + items: + $ref: './common.schema.yaml#/components/schemas/EngineDescriptor' diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 264d0eaa14fee..513e2163f932f 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -231,6 +231,11 @@ import type { InternalUploadAssetCriticalityRecordsResponse, UploadAssetCriticalityRecordsResponse, } from './entity_analytics/asset_criticality/upload_asset_criticality_csv.gen'; +import type { + GetEntityStoreStatusResponse, + InitEntityStoreRequestBodyInput, + InitEntityStoreResponse, +} from './entity_analytics/entity_store/enablement.gen'; import type { ApplyEntityEngineDataviewIndicesResponse } from './entity_analytics/entity_store/engine/apply_dataview_indices.gen'; import type { DeleteEntityEngineRequestQueryInput, @@ -1301,6 +1306,18 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async getEntityStoreStatus() { + this.log.info(`${new Date().toISOString()} Calling API GetEntityStoreStatus`); + return this.kbnClient + .request({ + path: '/api/entity_store/status', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Get all notes for a given document. */ @@ -1529,6 +1546,19 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async initEntityStore(props: InitEntityStoreProps) { + this.log.info(`${new Date().toISOString()} Calling API InitEntityStore`); + return this.kbnClient + .request({ + path: '/api/entity_store/enable', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'POST', + body: props.body, + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Initializes the Risk Engine by creating the necessary indices and mappings, removing old transforms, and starting the new risk engine */ @@ -2290,6 +2320,9 @@ export interface InitEntityEngineProps { params: InitEntityEngineRequestParamsInput; body: InitEntityEngineRequestBodyInput; } +export interface InitEntityStoreProps { + body: InitEntityStoreRequestBodyInput; +} export interface InstallPrepackedTimelinesProps { body: InstallPrepackedTimelinesRequestBodyInput; } diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index 60bd38c246f34..fa79b170f3513 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -267,6 +267,42 @@ paths: summary: List asset criticality records tags: - Security Entity Analytics API + /api/entity_store/enable: + post: + operationId: InitEntityStore + requestBody: + content: + application/json: + schema: + type: object + properties: + fieldHistoryLength: + default: 10 + description: The number of historical values to keep for each field. + type: integer + filter: + type: string + indexPattern: + $ref: '#/components/schemas/IndexPattern' + description: Schema for the entity store initialization + required: true + responses: + '200': + content: + application/json: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/EngineDescriptor' + type: array + succeeded: + type: boolean + description: Successful response + summary: Initialize the Entity Store + tags: + - Security Entity Analytics API /api/entity_store/engines: get: operationId: ListEntityEngines @@ -576,6 +612,26 @@ paths: summary: List Entity Store Entities tags: - Security Entity Analytics API + /api/entity_store/status: + get: + operationId: GetEntityStoreStatus + responses: + '200': + content: + application/json: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/EngineDescriptor' + type: array + status: + $ref: '#/components/schemas/StoreStatus' + description: Successful response + summary: Get the status of the Entity Store + tags: + - Security Entity Analytics API /api/risk_score/engine/dangerously_delete_data: delete: description: >- @@ -1046,6 +1102,14 @@ components: - index - description - category + StoreStatus: + enum: + - not_installed + - installing + - running + - stopped + - error + type: string TaskManagerUnavailableResponse: description: Task manager is unavailable type: object diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index fc63924118968..9c2b3d62b1650 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -267,6 +267,42 @@ paths: summary: List asset criticality records tags: - Security Entity Analytics API + /api/entity_store/enable: + post: + operationId: InitEntityStore + requestBody: + content: + application/json: + schema: + type: object + properties: + fieldHistoryLength: + default: 10 + description: The number of historical values to keep for each field. + type: integer + filter: + type: string + indexPattern: + $ref: '#/components/schemas/IndexPattern' + description: Schema for the entity store initialization + required: true + responses: + '200': + content: + application/json: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/EngineDescriptor' + type: array + succeeded: + type: boolean + description: Successful response + summary: Initialize the Entity Store + tags: + - Security Entity Analytics API /api/entity_store/engines: get: operationId: ListEntityEngines @@ -576,6 +612,26 @@ paths: summary: List Entity Store Entities tags: - Security Entity Analytics API + /api/entity_store/status: + get: + operationId: GetEntityStoreStatus + responses: + '200': + content: + application/json: + schema: + type: object + properties: + engines: + items: + $ref: '#/components/schemas/EngineDescriptor' + type: array + status: + $ref: '#/components/schemas/StoreStatus' + description: Successful response + summary: Get the status of the Entity Store + tags: + - Security Entity Analytics API /api/risk_score/engine/dangerously_delete_data: delete: description: >- @@ -1046,6 +1102,14 @@ components: - index - description - category + StoreStatus: + enum: + - not_installed + - installing + - running + - stopped + - error + type: string TaskManagerUnavailableResponse: description: Task manager is unavailable type: object diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts index 8b2e802b17b6d..dc455e3006e3d 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { EngineStatus } from '../../../../common/api/entity_analytics'; +import type { EngineStatus, StoreStatus } from '../../../../common/api/entity_analytics'; export const DEFAULT_LOOKBACK_PERIOD = '24h'; @@ -17,4 +17,12 @@ export const ENGINE_STATUS: Record, EngineStatus> = { ERROR: 'error', }; +export const ENTITY_STORE_STATUS: Record, StoreStatus> = { + RUNNING: 'running', + STOPPED: 'stopped', + INSTALLING: 'installing', + NOT_INSTALLED: 'not_installed', + ERROR: 'error', +}; + export const MAX_SEARCH_RESPONSE_SIZE = 10_000; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts index 7413c365b5da6..b99fa8935b692 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts @@ -20,17 +20,22 @@ import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import type { DataViewsService } from '@kbn/data-views-plugin/common'; import { isEqual } from 'lodash/fp'; import moment from 'moment'; +import type { + GetEntityStoreStatusResponse, + InitEntityStoreRequestBody, + InitEntityStoreResponse, +} from '../../../../common/api/entity_analytics/entity_store/enablement.gen'; import type { AppClient } from '../../..'; +import { EntityType } from '../../../../common/api/entity_analytics'; import type { Entity, EngineDataviewUpdateResult, InitEntityEngineRequestBody, InitEntityEngineResponse, - EntityType, InspectQuery, } from '../../../../common/api/entity_analytics'; import { EngineDescriptorClient } from './saved_object/engine_descriptor'; -import { ENGINE_STATUS, MAX_SEARCH_RESPONSE_SIZE } from './constants'; +import { ENGINE_STATUS, ENTITY_STORE_STATUS, MAX_SEARCH_RESPONSE_SIZE } from './constants'; import { AssetCriticalityEcsMigrationClient } from '../asset_criticality/asset_criticality_migration_client'; import { getUnitedEntityDefinition } from './united_entity_definitions'; import { @@ -126,6 +131,44 @@ export class EntityStoreDataClient { }); } + public async enable( + { indexPattern = '', filter = '', fieldHistoryLength = 10 }: InitEntityStoreRequestBody, + { pipelineDebugMode = false }: { pipelineDebugMode?: boolean } = {} + ): Promise { + if (!this.options.taskManager) { + throw new Error('Task Manager is not available'); + } + + // Immediately defer the initialization to the next tick. This way we don't block on the init preflight checks + const run = (fn: () => Promise) => + new Promise((resolve) => setTimeout(() => fn().then(resolve), 0)); + const promises = Object.values(EntityType.Values).map((entity) => + run(() => + this.init(entity, { indexPattern, filter, fieldHistoryLength }, { pipelineDebugMode }) + ) + ); + + const engines = await Promise.all(promises); + return { engines, succeeded: true }; + } + + public async status(): Promise { + const { engines, count } = await this.engineClient.list(); + + let status = ENTITY_STORE_STATUS.RUNNING; + if (count === 0) { + status = ENTITY_STORE_STATUS.NOT_INSTALLED; + } else if (engines.some((engine) => engine.status === ENGINE_STATUS.ERROR)) { + status = ENTITY_STORE_STATUS.ERROR; + } else if (engines.every((engine) => engine.status === ENGINE_STATUS.STOPPED)) { + status = ENTITY_STORE_STATUS.STOPPED; + } else if (engines.some((engine) => engine.status === ENGINE_STATUS.INSTALLING)) { + status = ENTITY_STORE_STATUS.INSTALLING; + } + + return { engines, status }; + } + public async init( entityType: EntityType, { indexPattern = '', filter = '', fieldHistoryLength = 10 }: InitEntityEngineRequestBody, @@ -137,7 +180,16 @@ export class EntityStoreDataClient { const { config } = this.options; - await this.riskScoreDataClient.createRiskScoreLatestIndex(); + await this.riskScoreDataClient.createRiskScoreLatestIndex().catch((e) => { + if (e.meta.body.error.type === 'resource_already_exists_exception') { + this.options.logger.debug( + `Risk score index for ${entityType} already exists, skipping creation.` + ); + return; + } + + throw e; + }); const requiresMigration = await this.assetCriticalityMigrationClient.isEcsDataMigrationRequired(); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts new file mode 100644 index 0000000000000..16813fccdf235 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.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 type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { InitEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import { InitEntityStoreRequestBody } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { checkAndInitAssetCriticalityResources } from '../../asset_criticality/check_and_init_asset_criticality_resources'; + +export const enableEntityStoreRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger, + config: EntityAnalyticsRoutesDeps['config'] +) => { + router.versioned + .post({ + access: 'public', + path: '/api/entity_store/enable', + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + body: buildRouteValidationWithZod(InitEntityStoreRequestBody), + }, + }, + }, + + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + const secSol = await context.securitySolution; + const { pipelineDebugMode } = config.entityAnalytics.entityStore.developer; + + await checkAndInitAssetCriticalityResources(context, logger); + + try { + const body: InitEntityStoreResponse = await secSol + .getEntityStoreDataClient() + .enable(request.body, { pipelineDebugMode }); + + return response.ok({ body }); + } catch (e) { + const error = transformError(e); + logger.error(`Error initialising entity store: ${error.message}`); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts index 9784dcd619667..c3c66d0b32e28 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts @@ -15,6 +15,8 @@ import { listEntityEnginesRoute } from './list'; import { entityStoreInternalPrivilegesRoute } from './privileges'; import { startEntityEngineRoute } from './start'; import { stopEntityEngineRoute } from './stop'; +import { getEntityStoreStatusRoute } from './status'; +import { enableEntityStoreRoute } from './enablement'; export const registerEntityStoreRoutes = ({ router, @@ -22,6 +24,8 @@ export const registerEntityStoreRoutes = ({ getStartServices, config, }: EntityAnalyticsRoutesDeps) => { + enableEntityStoreRoute(router, logger, config); + getEntityStoreStatusRoute(router, logger, config); initEntityEngineRoute(router, logger, config); startEntityEngineRoute(router, logger); stopEntityEngineRoute(router, logger); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts new file mode 100644 index 0000000000000..7a59b59b9914a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts @@ -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. + */ + +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { checkAndInitAssetCriticalityResources } from '../../asset_criticality/check_and_init_asset_criticality_resources'; + +export const getEntityStoreStatusRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger, + config: EntityAnalyticsRoutesDeps['config'] +) => { + router.versioned + .get({ + access: 'public', + path: '/api/entity_store/status', + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: {}, + }, + + async ( + context, + request, + response + ): Promise> => { + const siemResponse = buildSiemResponse(response); + const secSol = await context.securitySolution; + + await checkAndInitAssetCriticalityResources(context, logger); + + try { + const body: GetEntityStoreStatusResponse = await secSol + .getEntityStoreDataClient() + .status(); + + return response.ok({ body }); + } catch (e) { + const error = transformError(e); + logger.error(`Error initialising entity store: ${error.message}`); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index 3cffbef413fa3..6ba76b071d860 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -106,6 +106,7 @@ import { InitEntityEngineRequestParamsInput, InitEntityEngineRequestBodyInput, } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/init.gen'; +import { InitEntityStoreRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/enablement.gen'; import { InstallPrepackedTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route.gen'; import { ListEntitiesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/entities/list_entities.gen'; import { PatchRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/patch_rule/patch_rule_route.gen'; @@ -842,6 +843,13 @@ finalize it. .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + getEntityStoreStatus(kibanaSpace: string = 'default') { + return supertest + .get(routeWithNamespace('/api/entity_store/status', kibanaSpace)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Get all notes for a given document. */ @@ -1030,6 +1038,14 @@ finalize it. .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + initEntityStore(props: InitEntityStoreProps, kibanaSpace: string = 'default') { + return supertest + .post(routeWithNamespace('/api/entity_store/enable', kibanaSpace)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send(props.body as object); + }, /** * Initializes the Risk Engine by creating the necessary indices and mappings, removing old transforms, and starting the new risk engine */ @@ -1633,6 +1649,9 @@ export interface InitEntityEngineProps { params: InitEntityEngineRequestParamsInput; body: InitEntityEngineRequestBodyInput; } +export interface InitEntityStoreProps { + body: InitEntityStoreRequestBodyInput; +} export interface InstallPrepackedTimelinesProps { body: InstallPrepackedTimelinesRequestBodyInput; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts similarity index 78% rename from x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts rename to x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts index f51fbd15ceead..1fbaaa9b3fc71 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts @@ -14,7 +14,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const utils = EntityStoreUtils(getService); - describe('@ess @skipInServerlessMKI Entity Store Engine APIs', () => { + describe('@ess @skipInServerlessMKI Entity Store APIs', () => { const dataView = dataViewRouteHelpersFactory(supertest); before(async () => { @@ -42,6 +42,18 @@ export default ({ getService }: FtrProviderContext) => { }); }); + describe('enablement', () => { + afterEach(async () => { + await utils.cleanEngines(); + }); + + it('should enable the entity store, creating both user and host engines', async () => { + await utils.enableEntityStore(); + await utils.expectEngineAssetsExist('user'); + await utils.expectEngineAssetsExist('host'); + }); + }); + describe('get and list', () => { before(async () => { await utils.initEntityEngineForEntityTypesAndWait(['host', 'user']); @@ -182,6 +194,42 @@ export default ({ getService }: FtrProviderContext) => { }); }); + describe('status', () => { + afterEach(async () => { + await utils.cleanEngines(); + }); + it('should return "not_installed" when no engines have been initialized', async () => { + const { body } = await api.getEntityStoreStatus().expect(200); + + expect(body).to.eql({ + engines: [], + status: 'not_installed', + }); + }); + + it('should return "installing" when at least one engine is being initialized', async () => { + await utils.enableEntityStore(); + + const { body } = await api.getEntityStoreStatus().expect(200); + + expect(body.status).to.eql('installing'); + expect(body.engines.length).to.eql(2); + expect(body.engines[0].status).to.eql('installing'); + expect(body.engines[1].status).to.eql('installing'); + }); + + it('should return "started" when all engines are started', async () => { + await utils.initEntityEngineForEntityTypesAndWait(['host', 'user']); + + const { body } = await api.getEntityStoreStatus().expect(200); + + expect(body.status).to.eql('running'); + expect(body.engines.length).to.eql(2); + expect(body.engines[0].status).to.eql('started'); + expect(body.engines[1].status).to.eql('started'); + }); + }); + describe('apply_dataview_indices', () => { before(async () => { await utils.initEntityEngineForEntityTypesAndWait(['host']); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine_nondefault_spaces.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store_nondefault_spaces.ts similarity index 100% rename from x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine_nondefault_spaces.ts rename to x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store_nondefault_spaces.ts diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/index.ts index 5f2d15db240c6..899dbc68102f3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/index.ts @@ -10,8 +10,8 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Entity Analytics - Entity Store', function () { loadTestFile(require.resolve('./entities_list')); - loadTestFile(require.resolve('./engine')); + loadTestFile(require.resolve('./entity_store')); loadTestFile(require.resolve('./field_retention_operators')); - loadTestFile(require.resolve('./engine_nondefault_spaces')); + loadTestFile(require.resolve('./entity_store_nondefault_spaces')); }); } diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/entity_store.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/entity_store.ts index 7ee32e20640d6..fff1040b81f29 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/entity_store.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/entity_store.ts @@ -90,6 +90,16 @@ export const EntityStoreUtils = ( ); }; + const enableEntityStore = async () => { + const res = await api.initEntityStore({ body: {} }, namespace); + if (res.status !== 200) { + log.error(`Failed to enable entity store`); + log.error(JSON.stringify(res.body)); + } + expect(res.status).to.eql(200); + return res; + }; + const expectTransformStatus = async ( transformId: string, exists: boolean, @@ -144,5 +154,6 @@ export const EntityStoreUtils = ( expectTransformStatus, expectEngineAssetsExist, expectEngineAssetsDoNotExist, + enableEntityStore, }; }; From ac0b0b4f05876f1c66f5b4fde7965a1955b90ec0 Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 19 Nov 2024 08:31:47 -0500 Subject: [PATCH 20/61] Enabling Full FTR, Integration, and Unit tests to the FIPS Test Pipeline (#192632) ## Summary Closes #192233 Just in time for Thanksgiving - a full buffet of FIPS testing fixes Usage of non-compliant algorithms manifest as runtime errors, so it is imperative that we attempt to run all tests possible with Kibana in FIPS mode. However, several overrides are needed to run Kibana in FIPS mode, resulting in setup that make it impossible to run. ## In this PR - Enable Unit tests for FIPS pipeline - Enable Integration Tests for FIPS pipeline - Enable Full FTR suite for FIPS pipeline (smoke test had originally run a subset) - Skip tests that break with overrides - Fix/change tests to work in FIPS mode to maximize coverage - Examine necessity of MD5 when installing from source (TBD based Ops PR feed back, see self review below) - Remove md5 from es_file_client options ## Latest Successful FIPS Test Run https://buildkite.com/elastic/kibana-fips/builds/268 --------- Co-authored-by: Brad White Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Aleh Zasypkin Co-authored-by: Larry Gregory --- .buildkite/pipelines/fips.yml | 7 +- .buildkite/scripts/steps/fips/smoke_test.sh | 24 -- .../scripts/steps/test/jest_parallel.sh | 9 +- .../src/security_service.test.ts | 26 +- .../tsconfig.json | 1 + .../src/create_root.ts | 24 +- .../tsconfig.json | 1 + packages/kbn-es/src/install/install_source.ts | 4 +- packages/kbn-test/src/es/test_es_cluster.ts | 6 +- .../config/check_dynamic_config.test.ts | 233 +++++++++--------- .../config/config_deprecation.test.ts | 21 +- .../default_route_provider_config.test.ts | 111 +++++---- .../version_compatibility.test.ts | 17 +- .../integration_tests/node/migrator.test.ts | 4 +- .../group3/multiple_es_nodes.test.ts | 167 +++++++------ .../migrations/group3/read_batch_size.test.ts | 51 ++-- .../serverless/migrations/smoke.test.ts | 39 +-- .../build/lib/integration_tests/fs.test.ts | 10 +- .../adapters/es/integration_tests/es.test.ts | 7 +- .../file_client/create_es_file_client.ts | 9 +- .../integration_tests/es_file_client.test.ts | 15 +- .../file_hash_transform.test.ts | 40 +-- .../group5/dashboard_panel_listing.ts | 3 +- .../http/ssl_with_p12/index.js | 3 +- .../http/ssl_with_p12_intermediate/index.js | 3 +- .../axios_utils_connection.test.ts | 51 ++-- .../plugins/fleet/.storybook/smoke.test.tsx | 32 ++- .../services/preconfiguration/outputs.ts | 1 - .../read_only_view.ts | 1 + 29 files changed, 520 insertions(+), 400 deletions(-) delete mode 100755 .buildkite/scripts/steps/fips/smoke_test.sh diff --git a/.buildkite/pipelines/fips.yml b/.buildkite/pipelines/fips.yml index f4a5b3623bbcc..e04a5ecb8f936 100644 --- a/.buildkite/pipelines/fips.yml +++ b/.buildkite/pipelines/fips.yml @@ -40,14 +40,15 @@ steps: machineType: n2-standard-2 preemptible: true - - command: .buildkite/scripts/steps/fips/smoke_test.sh - label: 'Pick Smoke Test Group Run Order' + - command: .buildkite/scripts/steps/test/pick_test_group_run_order.sh + label: 'Pick Test Group Run Order' depends_on: build timeout_in_minutes: 10 env: FTR_CONFIGS_SCRIPT: '.buildkite/scripts/steps/test/ftr_configs.sh' FTR_EXTRA_ARGS: '$FTR_EXTRA_ARGS' - LIMIT_CONFIG_TYPE: 'functional' + JEST_UNIT_SCRIPT: '.buildkite/scripts/steps/test/jest.sh' + JEST_INTEGRATION_SCRIPT: '.buildkite/scripts/steps/test/jest_integration.sh' retry: automatic: - exit_status: '*' diff --git a/.buildkite/scripts/steps/fips/smoke_test.sh b/.buildkite/scripts/steps/fips/smoke_test.sh deleted file mode 100755 index 685bb111ff81a..0000000000000 --- a/.buildkite/scripts/steps/fips/smoke_test.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -# Limit the FTR configs for now to avoid running all the tests. Once we're -# ready to utilize the full FTR suite in FIPS mode, we can remove this file and -# call pick_test_group_run_order.sh directly in .buildkite/pipelines/fips.yml. -configs=( - "x-pack/test/reporting_functional/reporting_and_security.config.ts" - "x-pack/test/saved_object_api_integration/security_and_spaces/config_trial.ts" - "x-pack/test/alerting_api_integration/security_and_spaces/group1/config.ts" - "x-pack/test/alerting_api_integration/security_and_spaces/group2/config.ts" - "x-pack/test/alerting_api_integration/security_and_spaces/group3/config.ts" - "x-pack/test/alerting_api_integration/security_and_spaces/group4/config.ts" - "x-pack/test/functional/apps/saved_objects_management/config.ts" - "x-pack/test/functional/apps/user_profiles/config.ts" - "x-pack/test/functional/apps/security/config.ts" -) - -printf -v FTR_CONFIG_PATTERNS '%s,' "${configs[@]}" -FTR_CONFIG_PATTERNS="${FTR_CONFIG_PATTERNS%,}" -export FTR_CONFIG_PATTERNS - -.buildkite/scripts/steps/test/pick_test_group_run_order.sh diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index 2a7cf780f5787..648c3b225141d 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -60,7 +60,14 @@ while read -r config; do # --trace-warnings to debug # Node.js process-warning detected: # Warning: Closing file descriptor 24 on garbage collection - cmd="NODE_OPTIONS=\"--max-old-space-size=12288 --trace-warnings\" node ./scripts/jest --config=\"$config\" $parallelism --coverage=false --passWithNoTests" + cmd="NODE_OPTIONS=\"--max-old-space-size=12288 --trace-warnings" + + if [ "${KBN_ENABLE_FIPS:-}" == "true" ]; then + cmd=$cmd" --enable-fips --openssl-config=$HOME/nodejs.cnf" + fi + + cmd=$cmd"\" node ./scripts/jest --config=\"$config\" $parallelism --coverage=false --passWithNoTests" + echo "actual full command is:" echo "$cmd" echo "" diff --git a/packages/core/security/core-security-server-internal/src/security_service.test.ts b/packages/core/security/core-security-server-internal/src/security_service.test.ts index 75539e9954ac0..0ff1e59db71ec 100644 --- a/packages/core/security/core-security-server-internal/src/security_service.test.ts +++ b/packages/core/security/core-security-server-internal/src/security_service.test.ts @@ -16,17 +16,32 @@ import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; import { mockCoreContext } from '@kbn/core-base-server-mocks'; import type { CoreSecurityDelegateContract } from '@kbn/core-security-server'; import { SecurityService } from './security_service'; +import { configServiceMock } from '@kbn/config-mocks'; +import { getFips } from 'crypto'; const createStubInternalContract = (): CoreSecurityDelegateContract => { return Symbol('stubContract') as unknown as CoreSecurityDelegateContract; }; -describe('SecurityService', () => { +describe('SecurityService', function () { let coreContext: ReturnType; + let configService: ReturnType; let service: SecurityService; beforeEach(() => { - coreContext = mockCoreContext.create(); + const mockConfig = { + xpack: { + security: { + experimental: { + fipsMode: { + enabled: !!getFips(), + }, + }, + }, + }, + }; + configService = configServiceMock.create({ getConfig$: mockConfig }); + coreContext = mockCoreContext.create({ configService }); service = new SecurityService(coreContext); convertSecurityApiMock.mockReset(); @@ -51,8 +66,11 @@ describe('SecurityService', () => { describe('#isEnabled', () => { it('should return boolean', () => { const { fips } = service.setup(); - - expect(fips.isEnabled()).toBe(false); + if (getFips() === 0) { + expect(fips.isEnabled()).toBe(false); + } else { + expect(fips.isEnabled()).toBe(true); + } }); }); }); diff --git a/packages/core/security/core-security-server-internal/tsconfig.json b/packages/core/security/core-security-server-internal/tsconfig.json index e1812dc77cf49..11128461daf4e 100644 --- a/packages/core/security/core-security-server-internal/tsconfig.json +++ b/packages/core/security/core-security-server-internal/tsconfig.json @@ -22,5 +22,6 @@ "@kbn/core-base-server-mocks", "@kbn/config", "@kbn/core-logging-server-mocks", + "@kbn/config-mocks", ] } diff --git a/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts b/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts index d2fa6850a8bf8..38dae90905cb2 100644 --- a/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts +++ b/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts @@ -12,10 +12,12 @@ import loadJsonFile from 'load-json-file'; import { defaultsDeep } from 'lodash'; import { BehaviorSubject } from 'rxjs'; import supertest from 'supertest'; +import { set } from '@kbn/safer-lodash-set'; import { getPackages } from '@kbn/repo-packages'; import { ToolingLog } from '@kbn/tooling-log'; import { REPO_ROOT } from '@kbn/repo-info'; +import { getFips } from 'crypto'; import { createTestEsCluster, CreateTestEsClusterOptions, @@ -75,6 +77,17 @@ export function createRootWithSettings( pkg.version = customKibanaVersion; } + /* + * Most of these integration tests expect OSS to default to true, but FIPS + * requires the security plugin to be enabled + */ + let oss = true; + if (getFips() === 1) { + set(settings, 'xpack.security.experimental.fipsMode.enabled', true); + oss = false; + delete cliArgs.oss; + } + const env = Env.createDefault( REPO_ROOT, { @@ -84,10 +97,10 @@ export function createRootWithSettings( watch: false, basePath: false, runExamples: false, - oss: true, disableOptimizer: true, cache: true, dist: false, + oss, ...cliArgs, }, repoPackages: getPackages(REPO_ROOT), @@ -255,7 +268,13 @@ export function createTestServers({ if (!adjustTimeout) { throw new Error('adjustTimeout is required in order to avoid flaky tests'); } - const license = settings.es?.license ?? 'basic'; + let license = settings.es?.license ?? 'basic'; + + if (getFips() === 1) { + // Set license to 'trial' if Node is running in FIPS mode + license = 'trial'; + } + const usersToBeAdded = settings.users ?? []; if (usersToBeAdded.length > 0) { if (license !== 'trial') { @@ -292,6 +311,7 @@ export function createTestServers({ hosts: es.getHostUrls(), username: kibanaServerTestUser.username, password: kibanaServerTestUser.password, + ...(getFips() ? kbnSettings.elasticsearch : {}), }; } diff --git a/packages/core/test-helpers/core-test-helpers-kbn-server/tsconfig.json b/packages/core/test-helpers/core-test-helpers-kbn-server/tsconfig.json index 85d14bb04ab59..65ca0ccdfca0b 100644 --- a/packages/core/test-helpers/core-test-helpers-kbn-server/tsconfig.json +++ b/packages/core/test-helpers/core-test-helpers-kbn-server/tsconfig.json @@ -20,6 +20,7 @@ "@kbn/repo-packages", "@kbn/es", "@kbn/dev-utils", + "@kbn/safer-lodash-set", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-es/src/install/install_source.ts b/packages/kbn-es/src/install/install_source.ts index 244b349002829..9a7e8f166791a 100644 --- a/packages/kbn-es/src/install/install_source.ts +++ b/packages/kbn-es/src/install/install_source.ts @@ -84,7 +84,7 @@ async function sourceInfo(cwd: string, license: string, log: ToolingLog = defaul log.info('on %s at %s', chalk.bold(branch), chalk.bold(sha)); log.info('%s locally modified file(s)', chalk.bold(status.modified.length)); - const etag = crypto.createHash('md5').update(branch); // eslint-disable-line @kbn/eslint/no_unsafe_hash + const etag = crypto.createHash('sha256').update(branch); etag.update(sha); // for changed files, use last modified times in hash calculation @@ -92,7 +92,7 @@ async function sourceInfo(cwd: string, license: string, log: ToolingLog = defaul etag.update(fs.statSync(path.join(cwd, file.path)).mtime.toString()); }); - const cwdHash = crypto.createHash('md5').update(cwd).digest('hex').substr(0, 8); // eslint-disable-line @kbn/eslint/no_unsafe_hash + const cwdHash = crypto.createHash('sha256').update(cwd).digest('hex').substr(0, 8); const basename = `${branch}-${task}-${cwdHash}`; const filename = `${basename}.${ext}`; diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 620147e1fa7ab..20c54e044e46f 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -21,6 +21,7 @@ import type { ToolingLog } from '@kbn/tooling-log'; import { REPO_ROOT } from '@kbn/repo-info'; import type { ArtifactLicense } from '@kbn/es'; import type { ServerlessOptions } from '@kbn/es/src/utils'; +import { getFips } from 'crypto'; import { CI_PARALLEL_PROCESS_PREFIX } from '../ci_parallel_process_prefix'; import { esTestConfig } from './es_test_config'; @@ -200,12 +201,15 @@ export function createTestEsCluster< const esArgs = assignArgs(defaultEsArgs, customEsArgs); + // Use 'trial' license if FIPS mode is enabled, otherwise use the provided license or default to 'basic' + const testLicense: ArtifactLicense = getFips() === 1 ? 'trial' : license ? license : 'basic'; + const config = { version: esVersion, installPath: Path.resolve(basePath, clusterName), sourcePath: Path.resolve(REPO_ROOT, '../elasticsearch'), + license: testLicense, password, - license, basePath, esArgs, resources: files, diff --git a/src/core/server/integration_tests/config/check_dynamic_config.test.ts b/src/core/server/integration_tests/config/check_dynamic_config.test.ts index 85061b876eebf..38250076385b9 100644 --- a/src/core/server/integration_tests/config/check_dynamic_config.test.ts +++ b/src/core/server/integration_tests/config/check_dynamic_config.test.ts @@ -16,128 +16,135 @@ import { request, } from '@kbn/core-test-helpers-kbn-server'; import { PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH } from '@kbn/core-plugins-server-internal/src/constants'; - -describe('PUT /internal/core/_settings', () => { - let esServer: TestElasticsearchUtils; - let root: Root; - - const loggerName = 'my-test-logger'; - - beforeAll(async () => { - const settings = { - coreApp: { allowDynamicConfigOverrides: true }, - logging: { - loggers: [{ name: loggerName, level: 'error', appenders: ['console'] }], - }, - server: { restrictInternalApis: false }, - }; - const { startES, startKibana } = createTestServers({ - adjustTimeout: (t: number) => jest.setTimeout(t), - settings: { - kbn: settings, - }, +import { getFips } from 'crypto'; + +if (getFips() === 0) { + describe('PUT /internal/core/_settings', () => { + let esServer: TestElasticsearchUtils; + let root: Root; + + const loggerName = 'my-test-logger'; + + beforeAll(async () => { + const settings = { + coreApp: { allowDynamicConfigOverrides: true }, + logging: { + loggers: [{ name: loggerName, level: 'error', appenders: ['console'] }], + }, + server: { restrictInternalApis: false }, + }; + const { startES, startKibana } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + kbn: settings, + }, + }); + + esServer = await startES(); + + const kbnUtils = await startKibana(); + root = kbnUtils.root; + + // eslint-disable-next-line dot-notation + root['server'].configService.addDynamicConfigPaths('logging', ['loggers']); // just for the sake of being able to change something easy to test }); - esServer = await startES(); - - const kbnUtils = await startKibana(); - root = kbnUtils.root; - - // eslint-disable-next-line dot-notation - root['server'].configService.addDynamicConfigPaths('logging', ['loggers']); // just for the sake of being able to change something easy to test - }); + afterAll(async () => { + await root?.shutdown(); + await esServer?.stop(); + }); - afterAll(async () => { - await root?.shutdown(); - await esServer?.stop(); - }); + test('should update the log level', async () => { + const logger = root.logger.get(loggerName); + expect(logger.isLevelEnabled('info')).toBe(false); + await request + .put(root, '/internal/core/_settings') + .set('Elastic-Api-Version', '1') + .send({ 'logging.loggers': [{ name: loggerName, level: 'debug', appenders: ['console'] }] }) + .expect(200); + expect(logger.isLevelEnabled('info')).toBe(true); + }); - test('should update the log level', async () => { - const logger = root.logger.get(loggerName); - expect(logger.isLevelEnabled('info')).toBe(false); - await request - .put(root, '/internal/core/_settings') - .set('Elastic-Api-Version', '1') - .send({ 'logging.loggers': [{ name: loggerName, level: 'debug', appenders: ['console'] }] }) - .expect(200); - expect(logger.isLevelEnabled('info')).toBe(true); + test('should remove the setting', async () => { + const logger = root.logger.get(loggerName); + expect(logger.isLevelEnabled('info')).toBe(true); // still true from the previous test + await request + .put(root, '/internal/core/_settings') + .set('Elastic-Api-Version', '1') + .send({ 'logging.loggers': null }) + .expect(200); + expect(logger.isLevelEnabled('info')).toBe(false); + }); }); - test('should remove the setting', async () => { - const logger = root.logger.get(loggerName); - expect(logger.isLevelEnabled('info')).toBe(true); // still true from the previous test - await request - .put(root, '/internal/core/_settings') - .set('Elastic-Api-Version', '1') - .send({ 'logging.loggers': null }) - .expect(200); - expect(logger.isLevelEnabled('info')).toBe(false); - }); -}); - -describe('checking all opted-in dynamic config settings', () => { - let root: Root; - - beforeAll(async () => { - const settings = { - logging: { - loggers: [{ name: 'root', level: 'info', appenders: ['console'] }], - }, - server: { - restrictInternalApis: false, - }, - }; - - set(settings, PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH, true); - - root = createRootWithCorePlugins(settings, { - basePath: false, - cache: false, - dev: true, - disableOptimizer: true, - silent: false, - dist: false, - oss: false, - runExamples: false, - watch: false, + describe('checking all opted-in dynamic config settings', () => { + let root: Root; + + beforeAll(async () => { + const settings = { + logging: { + loggers: [{ name: 'root', level: 'info', appenders: ['console'] }], + }, + server: { + restrictInternalApis: false, + }, + }; + + set(settings, PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH, true); + + root = createRootWithCorePlugins(settings, { + basePath: false, + cache: false, + dev: true, + disableOptimizer: true, + silent: false, + dist: false, + oss: false, + runExamples: false, + watch: false, + }); + + await root.preboot(); + await root.setup(); }); - await root.preboot(); - await root.setup(); - }); + afterAll(async () => { + if (root) { + await root.shutdown(); + } + }); - afterAll(async () => { - if (root) { - await root.shutdown(); + function getListOfDynamicConfigPaths(): string[] { + // eslint-disable-next-line dot-notation + return [...root['server']['configService']['dynamicPaths'].entries()] + .flatMap(([configPath, dynamicConfigKeys]) => { + return dynamicConfigKeys.map( + (dynamicConfigKey: string) => `${configPath}.${dynamicConfigKey}` + ); + }) + .sort(); } - }); - function getListOfDynamicConfigPaths(): string[] { - // eslint-disable-next-line dot-notation - return [...root['server']['configService']['dynamicPaths'].entries()] - .flatMap(([configPath, dynamicConfigKeys]) => { - return dynamicConfigKeys.map( - (dynamicConfigKey: string) => `${configPath}.${dynamicConfigKey}` - ); - }) - .sort(); - } - - /** - * This test is meant to fail when any setting is flagged as capable - * of dynamic configuration {@link PluginConfigDescriptor.dynamicConfig}. - * - * Please, add your settings to the list with a comment of why it's required to be dynamic. - * - * The intent is to trigger a code review from the Core and Security teams to discuss potential issues. - */ - test('detecting all the settings that have opted-in for dynamic in-memory updates', () => { - expect(getListOfDynamicConfigPaths()).toStrictEqual([ - // Making testing easier by having the ability of overriding the feature flags without the need to restart - 'feature_flags.overrides', - // We need this for enriching our Perf tests with more valuable data regarding the steps of the test - // Also helpful in Cloud & Serverless testing because we can't control the labels in those offerings - 'telemetry.labels', - ]); + /** + * This test is meant to fail when any setting is flagged as capable + * of dynamic configuration {@link PluginConfigDescriptor.dynamicConfig}. + * + * Please, add your settings to the list with a comment of why it's required to be dynamic. + * + * The intent is to trigger a code review from the Core and Security teams to discuss potential issues. + */ + test('detecting all the settings that have opted-in for dynamic in-memory updates', () => { + expect(getListOfDynamicConfigPaths()).toStrictEqual([ + // Making testing easier by having the ability of overriding the feature flags without the need to restart + 'feature_flags.overrides', + // We need this for enriching our Perf tests with more valuable data regarding the steps of the test + // Also helpful in Cloud & Serverless testing because we can't control the labels in those offerings + 'telemetry.labels', + ]); + }); + }); +} else { + it('is running in FIPS mode, skipping tests since they fail due to FIPS overrides', () => { + expect(getFips()).toBe(1); }); -}); +} diff --git a/src/core/server/integration_tests/config/config_deprecation.test.ts b/src/core/server/integration_tests/config/config_deprecation.test.ts index 277a6080c377f..42425f10a4c97 100644 --- a/src/core/server/integration_tests/config/config_deprecation.test.ts +++ b/src/core/server/integration_tests/config/config_deprecation.test.ts @@ -10,6 +10,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { mockLoggingSystem } from './config_deprecation.test.mocks'; import { createRoot } from '@kbn/core-test-helpers-kbn-server'; +import { getFips } from 'crypto'; describe('configuration deprecations', () => { let root: ReturnType; @@ -24,13 +25,19 @@ describe('configuration deprecations', () => { } }); - it('should not log deprecation warnings for default configuration', async () => { - root = createRoot(); + if (getFips() === 0) { + it('should not log deprecation warnings for default configuration', async () => { + root = createRoot(); - await root.preboot(); - await root.setup(); + await root.preboot(); + await root.setup(); - const logs = loggingSystemMock.collect(mockLoggingSystem); - expect(logs.warn.flat()).toHaveLength(0); - }); + const logs = loggingSystemMock.collect(mockLoggingSystem); + expect(logs.warn.flat()).toHaveLength(0); + }); + } else { + it('fips is enabled and the default configuration has been overridden', () => { + expect(getFips()).toBe(1); + }); + } }); diff --git a/src/core/server/integration_tests/core_app/default_route_provider_config.test.ts b/src/core/server/integration_tests/core_app/default_route_provider_config.test.ts index 57646007a586f..a3338e7d45468 100644 --- a/src/core/server/integration_tests/core_app/default_route_provider_config.test.ts +++ b/src/core/server/integration_tests/core_app/default_route_provider_config.test.ts @@ -14,74 +14,81 @@ import { request, type TestElasticsearchUtils, } from '@kbn/core-test-helpers-kbn-server'; +import { getFips } from 'crypto'; describe('default route provider', () => { let esServer: TestElasticsearchUtils; let root: Root; - beforeAll(async () => { - const { startES } = createTestServers({ - adjustTimeout: (t: number) => jest.setTimeout(t), + if (getFips() === 0) { + beforeAll(async () => { + const { startES } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + }); + esServer = await startES(); + root = createRootWithCorePlugins({ + server: { + basePath: '/hello', + restrictInternalApis: false, + }, + }); + + await root.preboot(); + await root.setup(); + await root.start(); }); - esServer = await startES(); - root = createRootWithCorePlugins({ - server: { - basePath: '/hello', - restrictInternalApis: false, - }, + + afterAll(async () => { + await esServer.stop(); + await root.shutdown(); }); - await root.preboot(); - await root.setup(); - await root.start(); - }); + it('redirects to the configured default route respecting basePath', async function () { + const { status, header } = await request.get(root, '/'); - afterAll(async () => { - await esServer.stop(); - await root.shutdown(); - }); + expect(status).toEqual(302); + expect(header).toMatchObject({ + location: '/hello/app/home', + }); + }); - it('redirects to the configured default route respecting basePath', async function () { - const { status, header } = await request.get(root, '/'); + it('ignores invalid values', async function () { + const invalidRoutes = [ + 'http://not-your-kibana.com', + '///example.com', + '//example.com', + ' //example.com', + ]; - expect(status).toEqual(302); - expect(header).toMatchObject({ - location: '/hello/app/home', - }); - }); + for (const url of invalidRoutes) { + await request + .post(root, '/internal/kibana/settings/defaultRoute') + .send({ value: url }) + .expect(400); + } - it('ignores invalid values', async function () { - const invalidRoutes = [ - 'http://not-your-kibana.com', - '///example.com', - '//example.com', - ' //example.com', - ]; + const { status, header } = await request.get(root, '/'); + expect(status).toEqual(302); + expect(header).toMatchObject({ + location: '/hello/app/home', + }); + }); - for (const url of invalidRoutes) { + it('consumes valid values', async function () { await request .post(root, '/internal/kibana/settings/defaultRoute') - .send({ value: url }) - .expect(400); - } + .send({ value: '/valid' }) + .expect(200); - const { status, header } = await request.get(root, '/'); - expect(status).toEqual(302); - expect(header).toMatchObject({ - location: '/hello/app/home', + const { status, header } = await request.get(root, '/'); + expect(status).toEqual(302); + expect(header).toMatchObject({ + location: '/hello/valid', + }); }); - }); - - it('consumes valid values', async function () { - await request - .post(root, '/internal/kibana/settings/defaultRoute') - .send({ value: '/valid' }) - .expect(200); - - const { status, header } = await request.get(root, '/'); - expect(status).toEqual(302); - expect(header).toMatchObject({ - location: '/hello/valid', + } else { + it('should have fips enabled, the overrides prevent these tests from working', () => { + expect(getFips()).toBe(1); }); - }); + } }); diff --git a/src/core/server/integration_tests/elasticsearch/version_compatibility.test.ts b/src/core/server/integration_tests/elasticsearch/version_compatibility.test.ts index d63895220ceb1..c2cb6fccc3cd0 100644 --- a/src/core/server/integration_tests/elasticsearch/version_compatibility.test.ts +++ b/src/core/server/integration_tests/elasticsearch/version_compatibility.test.ts @@ -17,6 +17,7 @@ import { firstValueFrom, Subject } from 'rxjs'; import { CliArgs } from '@kbn/config'; import Semver from 'semver'; import { unsafeConsole } from '@kbn/security-hardening'; +import { getFips } from 'crypto'; function nextMinor() { return Semver.inc(esTestConfig.getVersion(), 'minor') || '10.0.0'; @@ -130,9 +131,15 @@ describe('Version Compatibility', () => { ); }); - it('should ignore version mismatch when running on serverless mode and complete startup', async () => { - await expect( - startServers({ customKibanaVersion: nextMinor(), cliArgs: { serverless: true } }) - ).resolves.toBeUndefined(); - }); + if (getFips() === 0) { + it('should ignore version mismatch when running on serverless mode and complete startup', async () => { + await expect( + startServers({ customKibanaVersion: nextMinor(), cliArgs: { serverless: true } }) + ).resolves.toBeUndefined(); + }); + } else { + it('fips is enabled, serverless doesnt like the config overrides', () => { + expect(getFips()).toBe(1); + }); + } }); diff --git a/src/core/server/integration_tests/node/migrator.test.ts b/src/core/server/integration_tests/node/migrator.test.ts index cd4ab1cd8c74e..f899d7da5cde0 100644 --- a/src/core/server/integration_tests/node/migrator.test.ts +++ b/src/core/server/integration_tests/node/migrator.test.ts @@ -16,6 +16,7 @@ import { ToolingLog } from '@kbn/tooling-log'; import { createTestEsCluster, kibanaServerTestUser } from '@kbn/test'; import { observeLines } from '@kbn/stdio-dev-helpers'; import { REPO_ROOT } from '@kbn/repo-info'; +import { getFips } from 'crypto'; describe('migrator-only node', () => { const log = new ToolingLog({ writeTo: process.stdout, level: 'debug' }); @@ -30,6 +31,7 @@ describe('migrator-only node', () => { let logsSub: undefined | Rx.Subscription; try { await es.start(); + const isFipsEnabled = getFips(); proc = ChildProcess.spawn( process.execPath, @@ -42,7 +44,7 @@ describe('migrator-only node', () => { '--no-optimizer', '--no-base-path', '--no-watch', - '--oss', + isFipsEnabled ? '--xpack.security.experimental.fipsMode.enabled=true' : '--oss', ], { stdio: ['pipe', 'pipe', 'pipe'] } ); diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/multiple_es_nodes.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/multiple_es_nodes.test.ts index 490dea4c06be6..6898962077b9c 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/multiple_es_nodes.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/multiple_es_nodes.test.ts @@ -18,6 +18,7 @@ import { } from '@kbn/core-test-helpers-kbn-server'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { Root } from '@kbn/core-root-server-internal'; +import { getFips } from 'crypto'; const LOG_FILE_PREFIX = 'migration_test_multiple_es_nodes'; @@ -114,89 +115,95 @@ describe('migration v2', () => { } }); - it('migrates saved objects normally with multiple ES nodes', async () => { - const { startES } = createTestServers({ - adjustTimeout: (t: number) => jest.setTimeout(t), - settings: { - es: { - license: 'basic', - clusterName: 'es-test-cluster', - nodes: [ - { - name: 'node-01', - // original SO (5000 total; 2500 of type `foo` + 2500 of type `bar`): - // [ - // { id: 'foo:1', type: 'foo', foo: { status: 'not_migrated_1' } }, - // { id: 'bar:1', type: 'bar', bar: { status: 'not_migrated_1' } }, - // { id: 'foo:2', type: 'foo', foo: { status: 'not_migrated_2' } }, - // { id: 'bar:2', type: 'bar', bar: { status: 'not_migrated_2' } }, - // ]; - dataArchive: Path.join(__dirname, '..', 'archives', '7.13.0_5k_so_node_01.zip'), - }, - { - name: 'node-02', - dataArchive: Path.join(__dirname, '..', 'archives', '7.13.0_5k_so_node_02.zip'), - }, - ], + if (getFips() === 0) { + it('migrates saved objects normally with multiple ES nodes', async () => { + const { startES } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + clusterName: 'es-test-cluster', + nodes: [ + { + name: 'node-01', + // original SO (5000 total; 2500 of type `foo` + 2500 of type `bar`): + // [ + // { id: 'foo:1', type: 'foo', foo: { status: 'not_migrated_1' } }, + // { id: 'bar:1', type: 'bar', bar: { status: 'not_migrated_1' } }, + // { id: 'foo:2', type: 'foo', foo: { status: 'not_migrated_2' } }, + // { id: 'bar:2', type: 'bar', bar: { status: 'not_migrated_2' } }, + // ]; + dataArchive: Path.join(__dirname, '..', 'archives', '7.13.0_5k_so_node_01.zip'), + }, + { + name: 'node-02', + dataArchive: Path.join(__dirname, '..', 'archives', '7.13.0_5k_so_node_02.zip'), + }, + ], + }, }, - }, - }); - - esServer = await startES(); - - root = createRoot({ - logFileName: Path.join(__dirname, `${LOG_FILE_PREFIX}.log`), - hosts: esServer.hosts, - }); - - await root.preboot(); - const setup = await root.setup(); - setup.savedObjects.registerType({ - name: 'foo', - hidden: false, - mappings: { properties: { status: { type: 'text' } } }, - namespaceType: 'agnostic', - migrations: { - '7.14.0': (doc) => { - if (doc.attributes?.status) { - doc.attributes.status = doc.attributes.status.replace('not_migrated', 'migrated'); - } - return doc; + }); + + esServer = await startES(); + + root = createRoot({ + logFileName: Path.join(__dirname, `${LOG_FILE_PREFIX}.log`), + hosts: esServer.hosts, + }); + + await root.preboot(); + const setup = await root.setup(); + setup.savedObjects.registerType({ + name: 'foo', + hidden: false, + mappings: { properties: { status: { type: 'text' } } }, + namespaceType: 'agnostic', + migrations: { + '7.14.0': (doc) => { + if (doc.attributes?.status) { + doc.attributes.status = doc.attributes.status.replace('not_migrated', 'migrated'); + } + return doc; + }, }, - }, - }); - setup.savedObjects.registerType({ - name: 'bar', - hidden: false, - mappings: { properties: { status: { type: 'text' } } }, - namespaceType: 'agnostic', - migrations: { - '7.14.0': (doc) => { - if (doc.attributes?.status) { - doc.attributes.status = doc.attributes.status.replace('not_migrated', 'migrated'); - } - return doc; + }); + setup.savedObjects.registerType({ + name: 'bar', + hidden: false, + mappings: { properties: { status: { type: 'text' } } }, + namespaceType: 'agnostic', + migrations: { + '7.14.0': (doc) => { + if (doc.attributes?.status) { + doc.attributes.status = doc.attributes.status.replace('not_migrated', 'migrated'); + } + return doc; + }, }, - }, + }); + + await root.start(); + const esClient = esServer.es.getClient(); + + const migratedFooDocs = await fetchDocs(esClient, migratedIndexAlias, 'foo'); + expect(migratedFooDocs.length).toBe(2500); + migratedFooDocs.forEach((doc, i) => { + expect(doc.id).toBe(`foo:${i}`); + expect(doc.foo.status).toBe(`migrated_${i}`); + expect(doc.typeMigrationVersion).toBe('7.14.0'); + }); + + const migratedBarDocs = await fetchDocs(esClient, migratedIndexAlias, 'bar'); + expect(migratedBarDocs.length).toBe(2500); + migratedBarDocs.forEach((doc, i) => { + expect(doc.id).toBe(`bar:${i}`); + expect(doc.bar.status).toBe(`migrated_${i}`); + expect(doc.typeMigrationVersion).toBe('7.14.0'); + }); }); - - await root.start(); - const esClient = esServer.es.getClient(); - - const migratedFooDocs = await fetchDocs(esClient, migratedIndexAlias, 'foo'); - expect(migratedFooDocs.length).toBe(2500); - migratedFooDocs.forEach((doc, i) => { - expect(doc.id).toBe(`foo:${i}`); - expect(doc.foo.status).toBe(`migrated_${i}`); - expect(doc.typeMigrationVersion).toBe('7.14.0'); - }); - - const migratedBarDocs = await fetchDocs(esClient, migratedIndexAlias, 'bar'); - expect(migratedBarDocs.length).toBe(2500); - migratedBarDocs.forEach((doc, i) => { - expect(doc.id).toBe(`bar:${i}`); - expect(doc.bar.status).toBe(`migrated_${i}`); - expect(doc.typeMigrationVersion).toBe('7.14.0'); + } else { + it('skips the test when running in FIPS mode since the data archives cause the es nodes to run with a basic license', () => { + expect(getFips()).toBe(1); }); - }); + } }); diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/read_batch_size.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/read_batch_size.test.ts index df809d8c4c173..9f970ed234d71 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/read_batch_size.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/read_batch_size.test.ts @@ -15,6 +15,7 @@ import { } from '@kbn/core-test-helpers-kbn-server'; import { clearLog, readLog, startElasticsearch } from '../kibana_migrator_test_kit'; import { delay } from '../test_utils'; +import { getFips } from 'crypto'; const logFilePath = join(__dirname, 'read_batch_size.log'); @@ -36,33 +37,39 @@ describe('migration v2 - read batch size', () => { await delay(5); // give it a few seconds... cause we always do ¯\_(ツ)_/¯ }); - it('reduces the read batchSize in half if a batch exceeds maxReadBatchSizeBytes', async () => { - root = createRoot({ maxReadBatchSizeBytes: 15000 }); - await root.preboot(); - await root.setup(); - await root.start(); + if (getFips() === 0) { + it('reduces the read batchSize in half if a batch exceeds maxReadBatchSizeBytes', async () => { + root = createRoot({ maxReadBatchSizeBytes: 15000 }); + await root.preboot(); + await root.setup(); + await root.start(); - // Check for migration steps present in the logs - logs = await readLog(logFilePath); + // Check for migration steps present in the logs + logs = await readLog(logFilePath); - expect(logs).toMatch( - /Read a batch with a response content length of \d+ bytes which exceeds migrations\.maxReadBatchSizeBytes, retrying by reducing the batch size in half to 15/ - ); - expect(logs).toMatch('[.kibana] Migration completed'); - }); + expect(logs).toMatch( + /Read a batch with a response content length of \d+ bytes which exceeds migrations\.maxReadBatchSizeBytes, retrying by reducing the batch size in half to 15/ + ); + expect(logs).toMatch('[.kibana] Migration completed'); + }); - it('does not reduce the read batchSize in half if no batches exceeded maxReadBatchSizeBytes', async () => { - root = createRoot({ maxReadBatchSizeBytes: 50000 }); - await root.preboot(); - await root.setup(); - await root.start(); + it('does not reduce the read batchSize in half if no batches exceeded maxReadBatchSizeBytes', async () => { + root = createRoot({ maxReadBatchSizeBytes: 50000 }); + await root.preboot(); + await root.setup(); + await root.start(); - // Check for migration steps present in the logs - logs = await readLog(logFilePath); + // Check for migration steps present in the logs + logs = await readLog(logFilePath); - expect(logs).not.toMatch('retrying by reducing the batch size in half to'); - expect(logs).toMatch('[.kibana] Migration completed'); - }); + expect(logs).not.toMatch('retrying by reducing the batch size in half to'); + expect(logs).toMatch('[.kibana] Migration completed'); + }); + } else { + it('cannot run tests with dataArchives that have a basic licesne in FIPS mode', () => { + expect(getFips()).toBe(1); + }); + } }); function createRoot({ maxReadBatchSizeBytes }: { maxReadBatchSizeBytes?: number }) { diff --git a/src/core/server/integration_tests/saved_objects/serverless/migrations/smoke.test.ts b/src/core/server/integration_tests/saved_objects/serverless/migrations/smoke.test.ts index e76bbd8d2d65b..27af749593e70 100644 --- a/src/core/server/integration_tests/saved_objects/serverless/migrations/smoke.test.ts +++ b/src/core/server/integration_tests/saved_objects/serverless/migrations/smoke.test.ts @@ -13,28 +13,35 @@ import { TestServerlessKibanaUtils, createTestServerlessInstances, } from '@kbn/core-test-helpers-kbn-server'; +import { getFips } from 'crypto'; -describe('Basic smoke test', () => { +describe('Basic smoke test', function () { let serverlessES: TestServerlessESUtils; let serverlessKibana: TestServerlessKibanaUtils; let root: TestServerlessKibanaUtils['root']; - beforeEach(async () => { - const { startES, startKibana } = createTestServerlessInstances({ - adjustTimeout: jest.setTimeout, + if (getFips() === 0) { + beforeEach(async () => { + const { startES, startKibana } = createTestServerlessInstances({ + adjustTimeout: jest.setTimeout, + }); + serverlessES = await startES(); + serverlessKibana = await startKibana(); + root = serverlessKibana.root; }); - serverlessES = await startES(); - serverlessKibana = await startKibana(); - root = serverlessKibana.root; - }); - afterEach(async () => { - await serverlessES?.stop(); - await serverlessKibana?.stop(); - }); + afterEach(async () => { + await serverlessES?.stop(); + await serverlessKibana?.stop(); + }); - test('it can start Kibana running against serverless ES', async () => { - const { body } = await request.get(root, '/api/status').expect(200); - expect(body).toMatchObject({ status: { overall: { level: 'available' } } }); - }); + test('it can start Kibana running against serverless ES', async () => { + const { body } = await request.get(root, '/api/status').expect(200); + expect(body).toMatchObject({ status: { overall: { level: 'available' } } }); + }); + } else { + test('FIPS is enabled, serverless doesnt like the config overrides', () => { + expect(getFips()).toBe(1); + }); + } }); diff --git a/src/dev/build/lib/integration_tests/fs.test.ts b/src/dev/build/lib/integration_tests/fs.test.ts index 2b1adac411f65..4a24da0e0b5f6 100644 --- a/src/dev/build/lib/integration_tests/fs.test.ts +++ b/src/dev/build/lib/integration_tests/fs.test.ts @@ -13,6 +13,7 @@ import { chmodSync, statSync } from 'fs'; import del from 'del'; import { mkdirp, write, read, getChildPaths, copyAll, getFileHash, untar, gunzip } from '../fs'; +import { getFips } from 'crypto'; const TMP = resolve(__dirname, '../__tmp__'); const FIXTURES = resolve(__dirname, '../__fixtures__'); @@ -266,9 +267,12 @@ describe('getFileHash()', () => { '7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730' ); }); - it('resolves with the md5 hash of a file', async () => { - expect(await getFileHash(BAR_TXT_PATH, 'md5')).toBe('c157a79031e1c40f85931829bc5fc552'); - }); + + if (getFips() !== 1) { + it('resolves with the md5 hash of a file', async () => { + expect(await getFileHash(BAR_TXT_PATH, 'md5')).toBe('c157a79031e1c40f85931829bc5fc552'); + }); + } }); describe('untar()', () => { diff --git a/src/plugins/files/server/blob_storage_service/adapters/es/integration_tests/es.test.ts b/src/plugins/files/server/blob_storage_service/adapters/es/integration_tests/es.test.ts index afbae27ec367d..ab440e60629b5 100644 --- a/src/plugins/files/server/blob_storage_service/adapters/es/integration_tests/es.test.ts +++ b/src/plugins/files/server/blob_storage_service/adapters/es/integration_tests/es.test.ts @@ -27,10 +27,13 @@ describe('Elasticsearch blob storage', () => { beforeAll(async () => { ElasticsearchBlobStorageClient.configureConcurrentTransfers(Infinity); - const { startES, startKibana } = createTestServers({ adjustTimeout: jest.setTimeout }); + + const { startES, startKibana } = createTestServers({ + adjustTimeout: jest.setTimeout, + }); manageES = await startES(); manageKbn = await startKibana(); - esClient = manageKbn.coreStart.elasticsearch.client.asInternalUser; + esClient = manageKbn.coreStart.elasticsearch.createClient('es.test').asInternalUser; }); afterAll(async () => { diff --git a/src/plugins/files/server/file_client/create_es_file_client.ts b/src/plugins/files/server/file_client/create_es_file_client.ts index ddfcfc0833e80..3302e878c3631 100644 --- a/src/plugins/files/server/file_client/create_es_file_client.ts +++ b/src/plugins/files/server/file_client/create_es_file_client.ts @@ -8,6 +8,7 @@ */ import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { getFips } from 'crypto'; import { ElasticsearchBlobStorageClient } from '../blob_storage_service'; import { FileClientImpl } from './file_client'; import type { FileClient } from './types'; @@ -66,12 +67,18 @@ export function createEsFileClient(arg: CreateEsFileClientArgs): FileClient { maxSizeBytes, indexIsAlias, } = arg; + + let hashes: Array<'sha1' | 'sha256' | 'sha512' | 'md5'> = ['sha1', 'sha256', 'sha512']; + if (getFips() !== 1) { + hashes = ['md5', ...hashes]; + } + return new FileClientImpl( { id: NO_FILE_KIND, http: {}, maxSizeBytes, - hashes: ['md5', 'sha1', 'sha256', 'sha512'], + hashes, }, new EsIndexFilesMetadataClient(metadataIndex, elasticsearchClient, logger, indexIsAlias), new ElasticsearchBlobStorageClient( diff --git a/src/plugins/files/server/file_client/integration_tests/es_file_client.test.ts b/src/plugins/files/server/file_client/integration_tests/es_file_client.test.ts index 3492db16ee1a9..16fd2e535763b 100644 --- a/src/plugins/files/server/file_client/integration_tests/es_file_client.test.ts +++ b/src/plugins/files/server/file_client/integration_tests/es_file_client.test.ts @@ -13,6 +13,7 @@ import { TestEnvironmentUtils, setupIntegrationEnvironment } from '../../test_ut import { createEsFileClient } from '../create_es_file_client'; import { FileClient } from '../types'; import { FileMetadata } from '../../../common'; +import { getFips } from 'crypto'; describe('ES-index-backed file client', () => { let esClient: TestEnvironmentUtils['esClient']; @@ -107,13 +108,21 @@ describe('ES-index-backed file client', () => { }); await file.uploadContent(Readable.from([Buffer.from('test')])); - expect(file.toJSON().hash).toStrictEqual({ - md5: '098f6bcd4621d373cade4e832627b4f6', + let expected: Record = { sha1: 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3', sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', sha512: 'ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff', - }); + }; + + if (getFips() !== 1) { + expected = { + md5: '098f6bcd4621d373cade4e832627b4f6', + ...expected, + }; + } + + expect(file.toJSON().hash).toStrictEqual(expected); await deleteFile({ id: file.id, hasContent: true }); }); diff --git a/src/plugins/files/server/file_client/stream_transforms/file_hash_transform/file_hash_transform.test.ts b/src/plugins/files/server/file_client/stream_transforms/file_hash_transform/file_hash_transform.test.ts index f0801cf763dd0..18f0cd6184e71 100644 --- a/src/plugins/files/server/file_client/stream_transforms/file_hash_transform/file_hash_transform.test.ts +++ b/src/plugins/files/server/file_client/stream_transforms/file_hash_transform/file_hash_transform.test.ts @@ -24,6 +24,8 @@ import { BlobStorageService } from '../../../blob_storage_service'; import { InternalFileShareService } from '../../../file_share_service'; import { InternalFileService } from '../../../file_service/internal_file_service'; +import { getFips } from 'crypto'; + describe('When using the FileHashTransform', () => { let file: IFile; let fileContent: Readable; @@ -75,23 +77,25 @@ describe('When using the FileHashTransform', () => { expect(() => fileHash.getFileHash()).toThrow('File hash generation not yet complete'); }); - it.each([ - ['md5', '098f6bcd4621d373cade4e832627b4f6'], - ['sha1', 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'], - ['sha256', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'], - [ - 'sha512', - 'ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff', - ], - ] as Array<[SupportedFileHashAlgorithm, string]>)( - 'should generate file hash using algorithm: %s', - async (algorithm, expectedHash) => { - const fileHash = createFileHashTransform(algorithm); - await file.uploadContent(fileContent, undefined, { - transforms: [fileHash], - }); + describe('algorithms', function () { + it.each([ + ...(getFips() !== 1 ? [['md5', '098f6bcd4621d373cade4e832627b4f6']] : []), + ['sha1', 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'], + ['sha256', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'], + [ + 'sha512', + 'ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff', + ], + ] as Array<[SupportedFileHashAlgorithm, string]>)( + 'should generate file hash using algorithm: %s', + async (algorithm, expectedHash) => { + const fileHash = createFileHashTransform(algorithm); + await file.uploadContent(fileContent, undefined, { + transforms: [fileHash], + }); - expect(fileHash.getFileHash()).toEqual({ algorithm, value: expectedHash }); - } - ); + expect(fileHash.getFileHash()).toEqual({ algorithm, value: expectedHash }); + } + ); + }); }); diff --git a/test/functional/apps/dashboard/group5/dashboard_panel_listing.ts b/test/functional/apps/dashboard/group5/dashboard_panel_listing.ts index 1685228699fc3..abd6b1c5dd1c1 100644 --- a/test/functional/apps/dashboard/group5/dashboard_panel_listing.ts +++ b/test/functional/apps/dashboard/group5/dashboard_panel_listing.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); - describe('dashboard panel listing', () => { + describe('dashboard panel listing', function () { + this.tags('skipFIPS'); before(async () => { await kibanaServer.savedObjects.cleanStandardList(); await kibanaServer.importExport.load( diff --git a/test/server_integration/http/ssl_with_p12/index.js b/test/server_integration/http/ssl_with_p12/index.js index b402ce548c6a1..21c09a8d7e63f 100644 --- a/test/server_integration/http/ssl_with_p12/index.js +++ b/test/server_integration/http/ssl_with_p12/index.js @@ -10,7 +10,8 @@ export default function ({ getService }) { const supertest = getService('supertest'); - describe('kibana server with ssl', () => { + describe('kibana server with ssl', function () { + this.tags('skipFIPS'); it('handles requests using ssl with a P12 keystore', async () => { await supertest.get('/').expect(302); }); diff --git a/test/server_integration/http/ssl_with_p12_intermediate/index.js b/test/server_integration/http/ssl_with_p12_intermediate/index.js index b01df762c7345..0fc4a2f793e20 100644 --- a/test/server_integration/http/ssl_with_p12_intermediate/index.js +++ b/test/server_integration/http/ssl_with_p12_intermediate/index.js @@ -10,7 +10,8 @@ export default function ({ getService }) { const supertest = getService('supertest'); - describe('kibana server with ssl', () => { + describe('kibana server with ssl', function () { + this.tags('skipFIPS'); it('handles requests using ssl with a P12 keystore that uses an intermediate CA', async () => { await supertest.get('/').expect(302); }); diff --git a/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts index a0454cb2bbc18..99559e8ce6b68 100644 --- a/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts +++ b/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts @@ -28,6 +28,7 @@ import { DEFAULT_MICROSOFT_GRAPH_API_SCOPE, DEFAULT_MICROSOFT_GRAPH_API_URL, } from '../../common'; +import { getFips } from 'crypto'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -251,19 +252,6 @@ describe('axios connections', () => { expect(res.status).toBe(200); }); - test('it works with pfx and passphrase in SSL overrides', async () => { - const { url, server } = await createServer({ useHttps: true, requestCert: true }); - testServer = server; - - const configurationUtilities = getACUfromConfig(); - const sslOverrides = { - pfx: KIBANA_P12, - passphrase: 'storepass', - }; - const res = await request({ axios, url, logger, configurationUtilities, sslOverrides }); - expect(res.status).toBe(200); - }); - test('it fails with cert and key but no ca in SSL overrides', async () => { const { url, server } = await createServer({ useHttps: true, requestCert: true }); testServer = server; @@ -278,18 +266,33 @@ describe('axios connections', () => { await expect(fn()).rejects.toThrow('certificate'); }); - test('it fails with pfx but no passphrase in SSL overrides', async () => { - const { url, server } = await createServer({ useHttps: true, requestCert: true }); - testServer = server; + if (getFips() !== 1) { + test('it works with pfx and passphrase in SSL overrides', async () => { + const { url, server } = await createServer({ useHttps: true, requestCert: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const sslOverrides = { + pfx: KIBANA_P12, + passphrase: 'storepass', + }; + const res = await request({ axios, url, logger, configurationUtilities, sslOverrides }); + expect(res.status).toBe(200); + }); - const configurationUtilities = getACUfromConfig(); - const sslOverrides = { - pfx: KIBANA_P12, - }; - const fn = async () => - await request({ axios, url, logger, configurationUtilities, sslOverrides }); - await expect(fn()).rejects.toThrow('mac verify'); - }); + test('it fails with pfx but no passphrase in SSL overrides', async () => { + const { url, server } = await createServer({ useHttps: true, requestCert: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const sslOverrides = { + pfx: KIBANA_P12, + }; + const fn = async () => + await request({ axios, url, logger, configurationUtilities, sslOverrides }); + await expect(fn()).rejects.toThrow('mac verify'); + }); + } test('it fails with a client-side certificate issued by an invalid ca', async () => { const { url, server } = await createServer({ useHttps: true, requestCert: true }); diff --git a/x-pack/plugins/fleet/.storybook/smoke.test.tsx b/x-pack/plugins/fleet/.storybook/smoke.test.tsx index a3984a42a5ab2..63c1199b75aa9 100644 --- a/x-pack/plugins/fleet/.storybook/smoke.test.tsx +++ b/x-pack/plugins/fleet/.storybook/smoke.test.tsx @@ -5,22 +5,30 @@ * 2.0. */ +import { getFips } from 'crypto'; + import { mount } from 'enzyme'; import { createElement } from 'react'; import { act } from 'react-dom/test-utils'; import initStoryshots from '@storybook/addon-storyshots'; -describe('Fleet Storybook Smoke', () => { - test('Init', async () => { - await initStoryshots({ - configPath: __dirname, - framework: 'react', - test: async ({ story }) => { - const renderer = mount(createElement(story.render)); - // wait until the element will perform all renders and resolve all promises (lazy loading, especially) - await act(() => new Promise((resolve) => setTimeout(resolve, 0))); - expect(renderer.html()).not.toContain('euiErrorBoundary'); - }, +describe('Fleet Storybook Smoke', function () { + if (getFips() !== 1) { + test('Init', async () => { + await initStoryshots({ + configPath: __dirname, + framework: 'react', + test: async ({ story }) => { + const renderer = mount(createElement(story.render)); + // wait until the element will perform all renders and resolve all promises (lazy loading, especially) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(renderer.html()).not.toContain('euiErrorBoundary'); + }, + }); + }); + } else { + test('fips is enabled', function () { + expect(getFips() === 1).toEqual(true); }); - }); + } }); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts index 9d71e15958480..714b16af5bcd2 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts @@ -152,7 +152,6 @@ export async function hashSecret(secret: string) { return `${salt}:${derivedKey.toString('hex')}`; } - async function verifySecret(hash: string, secret: string) { const [salt, key] = hash.split(':'); const derivedKey = await pbkdf2Async(secret, salt, maxIteration, keyLength, 'sha512'); diff --git a/x-pack/test/functional/apps/index_lifecycle_management/read_only_view.ts b/x-pack/test/functional/apps/index_lifecycle_management/read_only_view.ts index 030074a97b4bd..b30ee9ecee763 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/read_only_view.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/read_only_view.ts @@ -15,6 +15,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const security = getService('security'); describe('Read only view', function () { + this.tags('skipFIPS'); before(async () => { await security.testUser.setRoles(['read_ilm']); From 0c66148fc8480fa3fe844d0f304745dc4b62b946 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Tue, 19 Nov 2024 09:02:09 -0500 Subject: [PATCH 21/61] Swaps template literals for sprintf style interpolation (#200634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary String handling in error log output is now managed by console.error internals, for maximum safety re: external input. ## Comparison * The result of `formatErrors` is being passed to `toString()` so that the array stringification matches the previous version. Otherwise, it would include the `[ ]` brackets around the array in the new log output. * The `apiUrl` and `response` are still passed to `console.error` as the final two arguments, so they'll continue to be printed the same way as before. Previous output (using a local jest test): Screenshot 2024-11-18 at 1 24 11 PM Updated output: Screenshot 2024-11-18 at 1 46 34 PM The local jest test I used to confirm this was this: ```ts import { HttpSetup } from '@kbn/core-http-browser'; import { apiService } from './api_service'; import * as rt from 'io-ts'; describe('API service', () => { it('should log the right error', async () => { const mockHttp = { fetch: jest.fn(async () => ({ data: { myKey: 'myvalue' } })) } as unknown as HttpSetup; apiService.http = mockHttp; const ResponseType = rt.type({ data: rt.string }); await apiService.get( '/my/api/path', {}, ResponseType ); }); }); ``` and it was in `x-pack/plugins/observability_solution/synthetics/public/utils/api_service/api_service.test.ts` To run, I used ```sh node scripts/jest ./x-pack/plugins/observability_solution/synthetics/public/utils/api_service/api_service.test.ts ``` The test always passes but it allows you to quickly/easily see the output when the condition fails. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../synthetics/public/utils/api_service/api_service.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability_solution/synthetics/public/utils/api_service/api_service.ts b/x-pack/plugins/observability_solution/synthetics/public/utils/api_service/api_service.ts index 58c1d88226e5e..d16e34b430f1c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/utils/api_service/api_service.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/utils/api_service/api_service.ts @@ -54,11 +54,15 @@ class ApiService { if (isRight(decoded)) { return decoded.right as T; } else { + // This was changed from using template literals to using %s string + // interpolation, but the previous version included the apiUrl value + // twice. To ensure the log output doesn't change, this continues. + // // eslint-disable-next-line no-console console.error( - `API ${apiUrl} is not returning expected response, ${formatErrors( - decoded.left - )} for response`, + 'API %s is not returning expected response, %s for response', + apiUrl, + formatErrors(decoded.left).toString(), apiUrl, response ); From dde4271120d60242339ba1ef83396b3edfb4c4e2 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:22:08 +0100 Subject: [PATCH 22/61] [Indx Mgmt] Enable semantic text adaptive allocations by default (#200168) ## Summary This enables adaptive allocations for semantic text fields by default. --- .../create_field/semantic_text/use_semantic_text.test.ts | 4 +++- .../fields/create_field/semantic_text/use_semantic_text.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts index c4e668f9635d1..65415a287d94c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts @@ -258,7 +258,9 @@ describe('useSemanticText', () => { { service: 'elser', service_settings: { - num_allocations: 1, + adaptive_allocations: { + enabled: true, + }, num_threads: 1, model_id: '.elser_model_2', }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts index 42d220ba4724b..6662c2852ad7b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts @@ -85,7 +85,7 @@ export function useSemanticText(props: UseSemanticTextProps) { : { service: defaultInferenceEndpointConfig.service, service_settings: { - num_allocations: 1, + adaptive_allocations: { enabled: true }, num_threads: 1, model_id: trainedModelId, }, From 8352b86f59522319f6d20ae2165d11b621f1f22b Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 19 Nov 2024 15:27:19 +0100 Subject: [PATCH 23/61] [Fleet] Filter integrations/packages list shown depending on the `policy_templates_behavior` field (#200605) ## Summary Closes https://github.com/elastic/kibana/issues/198145 Add support to filter the tiles shown in the integrations UI as well as the packages shown in the global search provider depending on the `policy_templates_behaviour` field introduced in https://github.com/elastic/package-spec/issues/802. If this new field is not present in the package manifest, the same behavior is kept. Therefore, it is shown a tile for the package itself plus a tile for each policy template defined in the manifest. Tested using a custom Elastic Package Registry with some packages defining this new `policy_templates_behavior` via the key: ```yaml xpack.fleet.registryUrl: http://localhost:8080 ``` ### Screenshots Checked option "Elastic Agent only" in the integrations UI to avoid tutorials based on Beats. Example with `azure_metrics` package in the Integrations UI: - `policy_templates_behavior: all` ![All policy templates](https://github.com/user-attachments/assets/907618e3-f2db-44df-b1ac-3965b1978b2c) - `policy_templates_behavior: combined_policy` ![Just combined policy](https://github.com/user-attachments/assets/77293616-8125-4d01-81f3-b3f17135ca49) - `policy_templates_behavior: individual_policies` ![Just individual policy templates](https://github.com/user-attachments/assets/b68ad474-8aac-464b-9946-9ae6104dd2ae) Example in the Global Search with `azure_metrics` package and `combined policy` behavior: ![global search with azure metrics package](https://github.com/user-attachments/assets/e70315b7-d303-4b32-aa9e-8e1e9b056239) ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [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 - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] --- x-pack/plugins/fleet/common/services/index.ts | 1 + .../common/services/policy_template.test.ts | 124 +++++++++++ .../fleet/common/services/policy_template.ts | 25 +++ .../plugins/fleet/common/types/models/epm.ts | 1 + .../fleet/common/types/models/package_spec.ts | 1 + .../home/hooks/use_available_packages.tsx | 50 +++-- .../fleet/public/search_provider.test.ts | 200 ++++++++++++++++++ .../plugins/fleet/public/search_provider.ts | 9 +- 8 files changed, 385 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index 4443878617796..7061d6d3028d8 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -33,6 +33,7 @@ export { isIntegrationPolicyTemplate, getNormalizedInputs, getNormalizedDataStreams, + filterPolicyTemplatesTiles, } from './policy_template'; export { doesPackageHaveIntegrations } from './packages_with_integrations'; export type { diff --git a/x-pack/plugins/fleet/common/services/policy_template.test.ts b/x-pack/plugins/fleet/common/services/policy_template.test.ts index 87ce2121b8b59..b0cba311fe70c 100644 --- a/x-pack/plugins/fleet/common/services/policy_template.test.ts +++ b/x-pack/plugins/fleet/common/services/policy_template.test.ts @@ -10,6 +10,7 @@ import type { RegistryPolicyIntegrationTemplate, PackageInfo, RegistryVarType, + PackageListItem, } from '../types'; import { @@ -17,6 +18,7 @@ import { isIntegrationPolicyTemplate, getNormalizedInputs, getNormalizedDataStreams, + filterPolicyTemplatesTiles, } from './policy_template'; describe('isInputOnlyPolicyTemplate', () => { @@ -280,3 +282,125 @@ describe('getNormalizedDataStreams', () => { expect(result?.[0].streams?.[0]?.vars).toEqual([datasetVar]); }); }); + +describe('filterPolicyTemplatesTiles', () => { + const topPackagePolicy: PackageListItem = { + id: 'nginx', + integration: 'nginx', + title: 'Nginx', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }; + + const childPolicyTemplates: PackageListItem[] = [ + { + id: 'nginx-template1', + integration: 'nginx-template-1', + title: 'Nginx Template 1', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + { + id: 'nginx-template2', + integration: 'nginx-template-2', + title: 'Nginx Template 2', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + ]; + it('should return all tiles as undefined value', () => { + expect(filterPolicyTemplatesTiles(undefined, topPackagePolicy, childPolicyTemplates)).toEqual([ + { + id: 'nginx', + integration: 'nginx', + title: 'Nginx', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + { + id: 'nginx-template1', + integration: 'nginx-template-1', + title: 'Nginx Template 1', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + { + id: 'nginx-template2', + integration: 'nginx-template-2', + title: 'Nginx Template 2', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + ]); + }); + it('should return all tiles', () => { + expect(filterPolicyTemplatesTiles('all', topPackagePolicy, childPolicyTemplates)).toEqual([ + { + id: 'nginx', + integration: 'nginx', + title: 'Nginx', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + { + id: 'nginx-template1', + integration: 'nginx-template-1', + title: 'Nginx Template 1', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + { + id: 'nginx-template2', + integration: 'nginx-template-2', + title: 'Nginx Template 2', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + ]); + }); + it('should return just the combined policy tile', () => { + expect( + filterPolicyTemplatesTiles('combined_policy', topPackagePolicy, childPolicyTemplates) + ).toEqual([ + { + id: 'nginx', + integration: 'nginx', + title: 'Nginx', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + ]); + }); + it('should return just the individual policies (tiles)', () => { + expect( + filterPolicyTemplatesTiles('individual_policies', topPackagePolicy, childPolicyTemplates) + ).toEqual([ + { + id: 'nginx-template1', + integration: 'nginx-template-1', + title: 'Nginx Template 1', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + { + id: 'nginx-template2', + integration: 'nginx-template-2', + title: 'Nginx Template 2', + name: 'nginx', + version: '0.0.1', + status: 'not_installed', + }, + ]); + }); +}); diff --git a/x-pack/plugins/fleet/common/services/policy_template.ts b/x-pack/plugins/fleet/common/services/policy_template.ts index ed390e0c6b45d..efa65a880576a 100644 --- a/x-pack/plugins/fleet/common/services/policy_template.ts +++ b/x-pack/plugins/fleet/common/services/policy_template.ts @@ -39,6 +39,7 @@ export function packageHasNoPolicyTemplates(packageInfo: PackageInfo): boolean { ) ); } + export function isInputOnlyPolicyTemplate( policyTemplate: RegistryPolicyTemplate ): policyTemplate is RegistryPolicyInputOnlyTemplate { @@ -142,3 +143,27 @@ const createDefaultDatasetName = ( packageInfo: { name: string }, policyTemplate: { name: string } ): string => packageInfo.name + '.' + policyTemplate.name; + +export function filterPolicyTemplatesTiles( + templatesBehavior: string | undefined, + packagePolicy: T, + packagePolicyTemplates: T[] +): T[] { + switch (templatesBehavior || 'all') { + case 'combined_policy': + return [packagePolicy]; + case 'individual_policies': + return [ + ...(packagePolicyTemplates && packagePolicyTemplates.length > 1 + ? packagePolicyTemplates + : []), + ]; + default: + return [ + packagePolicy, + ...(packagePolicyTemplates && packagePolicyTemplates.length > 1 + ? packagePolicyTemplates + : []), + ]; + } +} diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index f1cd9e5ee4a7f..36a83c1c0a09e 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -311,6 +311,7 @@ export type RegistrySearchResult = Pick< | 'icons' | 'internal' | 'data_streams' + | 'policy_templates_behavior' | 'policy_templates' | 'categories' >; diff --git a/x-pack/plugins/fleet/common/types/models/package_spec.ts b/x-pack/plugins/fleet/common/types/models/package_spec.ts index 18c10e4617417..6ae8ba984f5f6 100644 --- a/x-pack/plugins/fleet/common/types/models/package_spec.ts +++ b/x-pack/plugins/fleet/common/types/models/package_spec.ts @@ -24,6 +24,7 @@ export interface PackageSpecManifest { conditions?: PackageSpecConditions; icons?: PackageSpecIcon[]; screenshots?: PackageSpecScreenshot[]; + policy_templates_behavior?: 'all' | 'combined_policy' | 'individual_policies'; policy_templates?: RegistryPolicyTemplate[]; vars?: RegistryVarsEntry[]; owner: { github?: string; type?: 'elastic' | 'partner' | 'community' }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx index 2f506b30b2626..c399a0241c22a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx @@ -28,6 +28,7 @@ import { doesPackageHaveIntegrations, ExperimentalFeaturesService } from '../../ import { isInputOnlyPolicyTemplate, isIntegrationPolicyTemplate, + filterPolicyTemplatesTiles, } from '../../../../../../../../common/services'; import { @@ -83,30 +84,33 @@ const packageListToIntegrationsList = (packages: PackageList): PackageList => { categories: getAllCategoriesFromIntegrations(pkg), }; - return [ - ...acc, + const integrationsPolicyTemplates = doesPackageHaveIntegrations(pkg) + ? policyTemplates.map((policyTemplate) => { + const { name, title, description, icons } = policyTemplate; + + const categories = + isIntegrationPolicyTemplate(policyTemplate) && policyTemplate.categories + ? policyTemplate.categories + : []; + const allCategories = [...topCategories, ...categories]; + return { + ...restOfPackage, + id: `${restOfPackage.id}-${name}`, + integration: name, + title, + description, + icons: icons || restOfPackage.icons, + categories: uniq(allCategories), + }; + }) + : []; + + const tiles = filterPolicyTemplatesTiles( + pkg.policy_templates_behavior, topPackage, - ...(doesPackageHaveIntegrations(pkg) - ? policyTemplates.map((policyTemplate) => { - const { name, title, description, icons } = policyTemplate; - - const categories = - isIntegrationPolicyTemplate(policyTemplate) && policyTemplate.categories - ? policyTemplate.categories - : []; - const allCategories = [...topCategories, ...categories]; - return { - ...restOfPackage, - id: `${restOfPackage.id}-${name}`, - integration: name, - title, - description, - icons: icons || restOfPackage.icons, - categories: uniq(allCategories), - }; - }) - : []), - ]; + integrationsPolicyTemplates + ); + return [...acc, ...tiles]; }, []); }; diff --git a/x-pack/plugins/fleet/public/search_provider.test.ts b/x-pack/plugins/fleet/public/search_provider.test.ts index 68ba3042a8e76..4ff04862c756f 100644 --- a/x-pack/plugins/fleet/public/search_provider.test.ts +++ b/x-pack/plugins/fleet/public/search_provider.test.ts @@ -112,6 +112,123 @@ const testResponse: GetPackagesResponse['items'] = [ }, ]; +const testResponseBehaviorField: GetPackagesResponse['items'] = [ + { + description: 'testWithPolicyTemplateBehaviorAll', + download: 'testWithPolicyTemplateBehaviorAll', + id: 'testWithPolicyTemplateBehaviorAll', + name: 'testWithPolicyTemplateBehaviorAll', + path: 'testWithPolicyTemplateBehaviorAll', + release: 'ga', + status: 'not_installed', + title: 'testWithPolicyTemplateBehaviorAll', + version: 'testWithPolicyTemplateBehaviorAll', + policy_templates_behavior: 'all', + policy_templates: [ + { + description: 'testPolicyTemplate1BehaviorAll', + name: 'testPolicyTemplate1BehaviorAll', + icons: [ + { + src: 'testPolicyTemplate1BehaviorAll', + path: 'testPolicyTemplate1BehaviorAll', + }, + ], + title: 'testPolicyTemplate1BehaviorAll', + type: 'testPolicyTemplate1BehaviorAll', + }, + { + description: 'testPolicyTemplate2BehaviorAll', + name: 'testPolicyTemplate2BehaviorAll', + icons: [ + { + src: 'testPolicyTemplate2BehaviorAll', + path: 'testPolicyTemplate2BehaviorAll', + }, + ], + title: 'testPolicyTemplate2BehaviorAll', + type: 'testPolicyTemplate2BehaviorAll', + }, + ], + }, + { + description: 'testWithPolicyTemplateBehaviorCombined', + download: 'testWithPolicyTemplateBehaviorCombined', + id: 'testWithPolicyTemplateBehaviorCombined', + name: 'testWithPolicyTemplateBehaviorCombined', + path: 'testWithPolicyTemplateBehaviorCombined', + release: 'ga', + status: 'not_installed', + title: 'testWithPolicyTemplateBehaviorCombined', + version: 'testWithPolicyTemplateBehaviorCombined', + policy_templates_behavior: 'combined_policy', + policy_templates: [ + { + description: 'testPolicyTemplate1BehaviorCombined', + name: 'testPolicyTemplate1BehaviorCombined', + icons: [ + { + src: 'testPolicyTemplate1BehaviorCombined', + path: 'testPolicyTemplate1BehaviorCombined', + }, + ], + title: 'testPolicyTemplate1BehaviorCombined', + type: 'testPolicyTemplate1BehaviorCombined', + }, + { + description: 'testPolicyTemplate2BehaviorCombined', + name: 'testPolicyTemplate2BehaviorCombined', + icons: [ + { + src: 'testPolicyTemplate2BehaviorCombined', + path: 'testPolicyTemplate2BehaviorCombined', + }, + ], + title: 'testPolicyTemplate2BehaviorCombined', + type: 'testPolicyTemplate2BehaviorCombined', + }, + ], + }, + { + description: 'testWithPolicyTemplateBehaviorIndividual', + download: 'testWithPolicyTemplateBehaviorIndividual', + id: 'testWithPolicyTemplateBehaviorIndividual', + name: 'testWithPolicyTemplateBehaviorIndividual', + path: 'testWithPolicyTemplateBehaviorIndividual', + release: 'ga', + status: 'not_installed', + title: 'testWithPolicyTemplateBehaviorIndividual', + version: 'testWithPolicyTemplateBehaviorIndividual', + policy_templates_behavior: 'individual_policies', + policy_templates: [ + { + description: 'testPolicyTemplate1BehaviorIndividual', + name: 'testPolicyTemplate1BehaviorIndividual', + icons: [ + { + src: 'testPolicyTemplate1BehaviorIndividual', + path: 'testPolicyTemplate1BehaviorIndividual', + }, + ], + title: 'testPolicyTemplate1BehaviorIndividual', + type: 'testPolicyTemplate1BehaviorIndividual', + }, + { + description: 'testPolicyTemplate2BehaviorIndividual', + name: 'testPolicyTemplate2BehaviorIndividual', + icons: [ + { + src: 'testPolicyTemplate2BehaviorIndividual', + path: 'testPolicyTemplate2BehaviorIndividual', + }, + ], + title: 'testPolicyTemplate2BehaviorIndividual', + type: 'testPolicyTemplate2BehaviorIndividual', + }, + ], + }, +]; + const getTestScheduler = () => { return new TestScheduler((actual, expected) => { return expect(actual).toEqual(expected); @@ -394,6 +511,89 @@ describe('Package search provider', () => { expect(sendGetPackages).toHaveBeenCalledTimes(1); }); + test('with integration tag, with policy_templates_behavior field', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { items: testResponseBehaviorField } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { types: ['integration'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('--(a|)', { + a: [ + { + id: 'testWithPolicyTemplateBehaviorAll', + score: 80, + title: 'testWithPolicyTemplateBehaviorAll', + type: 'integration', + url: { + path: 'undefined/detail/testWithPolicyTemplateBehaviorAll/overview', + prependBasePath: false, + }, + }, + { + id: 'testPolicyTemplate1BehaviorAll', + score: 80, + title: 'testPolicyTemplate1BehaviorAll', + type: 'integration', + url: { + path: 'undefined/detail/testWithPolicyTemplateBehaviorAll/overview?integration=testPolicyTemplate1BehaviorAll', + prependBasePath: false, + }, + }, + { + id: 'testPolicyTemplate2BehaviorAll', + score: 80, + title: 'testPolicyTemplate2BehaviorAll', + type: 'integration', + url: { + path: 'undefined/detail/testWithPolicyTemplateBehaviorAll/overview?integration=testPolicyTemplate2BehaviorAll', + prependBasePath: false, + }, + }, + { + id: 'testWithPolicyTemplateBehaviorCombined', + score: 80, + title: 'testWithPolicyTemplateBehaviorCombined', + type: 'integration', + url: { + path: 'undefined/detail/testWithPolicyTemplateBehaviorCombined/overview', + prependBasePath: false, + }, + }, + { + id: 'testPolicyTemplate1BehaviorIndividual', + score: 80, + title: 'testPolicyTemplate1BehaviorIndividual', + type: 'integration', + url: { + path: 'undefined/detail/testWithPolicyTemplateBehaviorIndividual/overview?integration=testPolicyTemplate1BehaviorIndividual', + prependBasePath: false, + }, + }, + { + id: 'testPolicyTemplate2BehaviorIndividual', + score: 80, + title: 'testPolicyTemplate2BehaviorIndividual', + type: 'integration', + url: { + path: 'undefined/detail/testWithPolicyTemplateBehaviorIndividual/overview?integration=testPolicyTemplate2BehaviorIndividual', + prependBasePath: false, + }, + }, + ], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(1); + }); + test('with integration tag, with search term', () => { getTestScheduler().run(({ hot, expectObservable }) => { mockSendGetPackages.mockReturnValue( diff --git a/x-pack/plugins/fleet/public/search_provider.ts b/x-pack/plugins/fleet/public/search_provider.ts index a6810633c428e..c329443288e4b 100644 --- a/x-pack/plugins/fleet/public/search_provider.ts +++ b/x-pack/plugins/fleet/public/search_provider.ts @@ -16,6 +16,7 @@ import type { } from '@kbn/global-search-plugin/public'; import { INTEGRATIONS_PLUGIN_ID } from '../common'; +import { filterPolicyTemplatesTiles } from '../common/services'; import { sendGetPackages } from './hooks'; import type { GetPackagesResponse, PackageListItem } from './types'; @@ -74,10 +75,12 @@ export const toSearchResult = ( }) ); - return [ + const tiles = filterPolicyTemplatesTiles( + pkg.policy_templates_behavior, packageResult, - ...(policyTemplateResults && policyTemplateResults.length > 1 ? policyTemplateResults : []), - ]; + policyTemplateResults || [] + ); + return [...tiles]; }; export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResultProvider => { From 455c781c6d1e1161f66e275299cf06064a0ffde2 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 19 Nov 2024 15:28:26 +0100 Subject: [PATCH 24/61] [LLM tasks] Add product documentation retrieval task (#194379) ## Summary Close https://github.com/elastic/kibana/issues/193473 Close https://github.com/elastic/kibana/issues/193474 This PR utilize the documentation packages that are build via the tool introduced by https://github.com/elastic/kibana/pull/193847, allowing to install them in Kibana and expose documentation retrieval as an LLM task that AI assistants (or other consumers) can call. Users can now decide to install the Elastic documentation from the assistant's config screen, which will expose a new tool for the assistant, `retrieve_documentation` (only implemented for the o11y assistant in the current PR, shall be done for security as a follow up). For more information, please refer to the self-review. ## General architecture Screenshot 2024-10-17 at 09 22 32 ## What this PR does Adds two plugin: - `productDocBase`: contains all the logic related to product documentation installation, status, and search. This is meant to be a "low level" components only responsible for this specific part. - `llmTasks`: an higher level plugin that will contain various LLM tasks to be used by assistants and genAI consumers. The intent is not to have a single place to put all llm tasks, but more to have a default place where we can introduce new tasks from. (fwiw, the `nlToEsql` task will probably be moved to that plugin). - Add a `retrieve_documentation` tool registration for the o11y assistant - Add a component on the o11y assistant configuration page to install the product doc (wiring the feature to the o11y assistant was done for testing purposes mostly, any addition / changes / enhancement should be done by the owning team - either in this PR or as a follow-up) ## What is NOT included in this PR: - Wire product base feature to the security assistant (should be done by the owning team as a follow-up) - installation - utilization as tool - FTR tests: this is somewhat blocked by the same things we need to figure out for https://github.com/elastic/kibana-team/issues/1271 ## Screenshots ### Installation from o11y assistant configuration page Screenshot 2024-10-17 at 09 41 24 ### Example of output #### Without product documentation installed Screenshot 2024-10-10 at 09 59 41 #### With product documentation installed Screenshot 2024-10-10 at 09 55 38 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alex Szabo Co-authored-by: Matthias Wilhelm Co-authored-by: Elastic Machine --- .github/CODEOWNERS | 3 + docs/developer/plugin-list.asciidoc | 8 + docs/user/security/audit-logging.asciidoc | 9 + package.json | 3 + .../current_fields.json | 7 + .../current_mappings.json | 20 ++ packages/kbn-optimizer/limits.yml | 1 + .../check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + tsconfig.base.json | 6 + .../product-doc-artifact-builder/README.md | 48 +++- .../src/artifact/manifest.ts | 8 +- .../src/artifact/mappings.ts | 5 +- .../src/artifact/product_name.ts | 35 ++- .../src/build_artifacts.ts | 8 +- .../src/command.ts | 8 +- .../src/tasks/create_artifact.ts | 4 +- .../src/tasks/create_chunk_files.ts | 2 +- .../src/tasks/create_index.ts | 5 +- .../src/tasks/extract_documentation.ts | 10 +- .../src/tasks/index.ts | 2 +- .../src/tasks/process_documents.ts | 59 ++++ .../product-doc-artifact-builder/src/types.ts | 4 +- .../tsconfig.json | 1 + .../ai-infra/product-doc-common/README.md | 3 + .../ai-infra/product-doc-common/index.ts | 17 ++ .../product-doc-common/jest.config.js | 12 + .../ai-infra/product-doc-common/kibana.jsonc | 5 + .../ai-infra/product-doc-common/package.json | 6 + .../product-doc-common/src/artifact.test.ts | 64 +++++ .../product-doc-common/src/artifact.ts | 39 +++ .../src/artifact_content.test.ts | 23 ++ .../src/artifact_content.ts | 12 + .../product-doc-common/src/documents.ts | 31 +++ .../product-doc-common/src/indices.ts | 15 ++ .../src/manifest.ts} | 14 +- .../product-doc-common/src/product.ts | 15 ++ .../ai-infra/product-doc-common/tsconfig.json | 17 ++ x-pack/plugins/ai_infra/llm_tasks/README.md | 45 ++++ .../plugins/ai_infra/llm_tasks/jest.config.js | 19 ++ .../plugins/ai_infra/llm_tasks/kibana.jsonc | 15 ++ .../ai_infra/llm_tasks/server/config.ts | 18 ++ .../ai_infra/llm_tasks/server/index.ts | 28 ++ .../ai_infra/llm_tasks/server/plugin.ts | 56 ++++ .../ai_infra/llm_tasks/server/tasks/index.ts | 8 + .../tasks/retrieve_documentation/index.ts | 13 + .../retrieve_documentation.test.ts | 182 +++++++++++++ .../retrieve_documentation.ts | 88 ++++++ .../summarize_document.ts | 67 +++++ .../tasks/retrieve_documentation/types.ts | 72 +++++ .../ai_infra/llm_tasks/server/types.ts | 42 +++ .../llm_tasks/server/utils/tokens.test.ts | 27 ++ .../ai_infra/llm_tasks/server/utils/tokens.ts | 21 ++ .../plugins/ai_infra/llm_tasks/tsconfig.json | 27 ++ .../ai_infra/product_doc_base/README.md | 3 + .../product_doc_base/common/consts.ts | 14 + .../common/http_api/installation.ts | 26 ++ .../product_doc_base/common/install_status.ts | 28 ++ .../ai_infra/product_doc_base/jest.config.js | 23 ++ .../ai_infra/product_doc_base/kibana.jsonc | 15 ++ .../ai_infra/product_doc_base/public/index.ts | 26 ++ .../product_doc_base/public/plugin.tsx | 51 ++++ .../public/services/installation/index.ts | 9 + .../installation/installation_service.test.ts | 79 ++++++ .../installation/installation_service.ts | 40 +++ .../public/services/installation/types.ts | 18 ++ .../ai_infra/product_doc_base/public/types.ts | 22 ++ .../product_doc_base/server/config.ts | 22 ++ .../ai_infra/product_doc_base/server/index.ts | 29 ++ .../product_doc_base/server/plugin.test.ts | 96 +++++++ .../product_doc_base/server/plugin.ts | 133 +++++++++ .../product_doc_base/server/routes/index.ts | 20 ++ .../server/routes/installation.ts | 115 ++++++++ .../server/saved_objects/index.ts | 11 + .../saved_objects/product_doc_install.ts | 46 ++++ .../services/doc_install_status/index.ts | 8 + .../model_conversion.test.ts | 44 +++ .../doc_install_status/model_conversion.ts | 26 ++ .../product_doc_install_service.test.ts | 65 +++++ .../product_doc_install_service.ts | 89 ++++++ .../doc_install_status/service.mock.ts | 24 ++ .../services/doc_manager/check_license.ts | 13 + .../services/doc_manager/doc_manager.test.ts | 247 +++++++++++++++++ .../services/doc_manager/doc_manager.ts | 204 ++++++++++++++ .../server/services/doc_manager/index.ts | 15 ++ .../server/services/doc_manager/types.ts | 98 +++++++ .../endpoint_manager.test.ts | 58 ++++ .../inference_endpoint/endpoint_manager.ts | 41 +++ .../services/inference_endpoint/index.ts | 8 + .../inference_endpoint/service.mock.ts | 20 ++ .../utils/get_model_install_status.ts | 34 +++ .../inference_endpoint/utils/index.ts | 10 + .../inference_endpoint/utils/install_elser.ts | 35 +++ .../utils/wait_until_model_deployed.ts | 39 +++ .../services/package_installer/index.ts | 8 + .../package_installer.test.mocks.ts | 36 +++ .../package_installer.test.ts | 255 ++++++++++++++++++ .../package_installer/package_installer.ts | 218 +++++++++++++++ .../steps/create_index.test.ts | 84 ++++++ .../package_installer/steps/create_index.ts | 50 ++++ .../steps/fetch_artifact_versions.test.ts | 129 +++++++++ .../steps/fetch_artifact_versions.ts | 59 ++++ .../services/package_installer/steps/index.ts | 11 + .../steps/populate_index.test.ts | 109 ++++++++ .../package_installer/steps/populate_index.ts | 84 ++++++ .../steps/validate_artifact_archive.test.ts | 73 +++++ .../steps/validate_artifact_archive.ts | 24 ++ .../utils/archive_accessors.test.ts | 78 ++++++ .../utils/archive_accessors.ts | 33 +++ .../package_installer/utils/download.ts | 23 ++ .../services/package_installer/utils/index.ts | 10 + .../package_installer/utils/semver.test.ts | 29 ++ .../package_installer/utils/semver.ts | 27 ++ .../utils/test_data/test_archive_1.zip | Bin 0 -> 800 bytes .../utils/zip_archive.test.ts | 43 +++ .../package_installer/utils/zip_archive.ts | 91 +++++++ .../server/services/search/index.ts | 9 + .../server/services/search/perform_search.ts} | 28 +- .../services/search/search_service.test.ts | 51 ++++ .../server/services/search/search_service.ts | 37 +++ .../server/services/search/types.ts | 27 ++ .../get_indices_for_product_names.test.ts | 22 ++ .../utils/get_indices_for_product_names.ts | 21 ++ .../server/services/search/utils/index.ts | 9 + .../services/search/utils/map_result.test.ts | 46 ++++ .../services/search/utils/map_result.ts | 19 ++ .../server/tasks/ensure_up_to_date.ts | 70 +++++ .../product_doc_base/server/tasks/index.ts | 29 ++ .../server/tasks/install_all.ts | 70 +++++ .../server/tasks/uninstall_all.ts | 70 +++++ .../product_doc_base/server/tasks/utils.ts | 69 +++++ .../ai_infra/product_doc_base/server/types.ts | 44 +++ .../ai_infra/product_doc_base/tsconfig.json | 29 ++ .../server/plugin.ts | 2 +- .../kibana.jsonc | 3 +- .../server/functions/documentation.ts | 82 ++++++ .../server/functions/index.ts | 2 + .../server/types.ts | 2 + .../tsconfig.json | 2 + .../kibana.jsonc | 3 +- .../public/constants.ts | 3 + .../hooks/use_get_product_doc_status.ts | 32 +++ .../public/hooks/use_install_product_doc.ts | 57 ++++ .../public/hooks/use_uninstall_product_doc.ts | 57 ++++ .../public/plugin.ts | 2 + .../settings_tab/product_doc_entry.tsx | 171 ++++++++++++ .../components/settings_tab/settings_tab.tsx | 3 + .../tsconfig.json | 3 +- .../check_registered_task_types.ts | 3 + yarn.lock | 12 + 150 files changed, 5662 insertions(+), 64 deletions(-) create mode 100644 x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/process_documents.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/README.md create mode 100644 x-pack/packages/ai-infra/product-doc-common/index.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/jest.config.js create mode 100644 x-pack/packages/ai-infra/product-doc-common/kibana.jsonc create mode 100644 x-pack/packages/ai-infra/product-doc-common/package.json create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/artifact.test.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/artifact.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/artifact_content.test.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/artifact_content.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/documents.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/indices.ts rename x-pack/packages/ai-infra/{product-doc-artifact-builder/src/artifact/artifact_name.ts => product-doc-common/src/manifest.ts} (59%) create mode 100644 x-pack/packages/ai-infra/product-doc-common/src/product.ts create mode 100644 x-pack/packages/ai-infra/product-doc-common/tsconfig.json create mode 100644 x-pack/plugins/ai_infra/llm_tasks/README.md create mode 100644 x-pack/plugins/ai_infra/llm_tasks/jest.config.js create mode 100644 x-pack/plugins/ai_infra/llm_tasks/kibana.jsonc create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/config.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/index.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/plugin.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/tasks/index.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/index.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.test.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/summarize_document.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/types.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/types.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.test.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.ts create mode 100644 x-pack/plugins/ai_infra/llm_tasks/tsconfig.json create mode 100644 x-pack/plugins/ai_infra/product_doc_base/README.md create mode 100644 x-pack/plugins/ai_infra/product_doc_base/common/consts.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/common/http_api/installation.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/common/install_status.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/jest.config.js create mode 100644 x-pack/plugins/ai_infra/product_doc_base/kibana.jsonc create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/plugin.tsx create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/services/installation/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/services/installation/types.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/public/types.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/config.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/plugin.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/plugin.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/routes/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/routes/installation.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/product_doc_install.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/service.mock.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/check_license.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/types.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/service.mock.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/get_model_install_status.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/install_elser.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/wait_until_model_deployed.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.mocks.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/download.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/test_data/test_archive_1.zip create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/index.ts rename x-pack/{packages/ai-infra/product-doc-artifact-builder/src/tasks/perform_semantic_search.ts => plugins/ai_infra/product_doc_base/server/services/search/perform_search.ts} (78%) create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/types.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.test.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/tasks/ensure_up_to_date.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/tasks/index.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/tasks/install_all.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/tasks/uninstall_all.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/tasks/utils.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/server/types.ts create mode 100644 x-pack/plugins/ai_infra/product_doc_base/tsconfig.json create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/documentation.ts create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_product_doc_status.ts create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_install_product_doc.ts create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_uninstall_product_doc.ts create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/product_doc_entry.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0894fbff896ad..d7e2cf7fda612 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -768,6 +768,7 @@ x-pack/examples/triggers_actions_ui_example @elastic/response-ops x-pack/examples/ui_actions_enhanced_examples @elastic/appex-sharedux x-pack/packages/ai-infra/inference-common @elastic/appex-ai-infra x-pack/packages/ai-infra/product-doc-artifact-builder @elastic/appex-ai-infra +x-pack/packages/ai-infra/product-doc-common @elastic/appex-ai-infra x-pack/packages/index-lifecycle-management/index_lifecycle_management_common_shared @elastic/kibana-management x-pack/packages/index-management/index_management_shared_types @elastic/kibana-management x-pack/packages/kbn-ai-assistant @elastic/search-kibana @@ -857,6 +858,8 @@ x-pack/packages/security/role_management_model @elastic/kibana-security x-pack/packages/security/ui_components @elastic/kibana-security x-pack/performance @elastic/appex-qa x-pack/plugins/actions @elastic/response-ops +x-pack/plugins/ai_infra/llm_tasks @elastic/appex-ai-infra +x-pack/plugins/ai_infra/product_doc_base @elastic/appex-ai-infra x-pack/plugins/aiops @elastic/ml-ui x-pack/plugins/alerting @elastic/response-ops x-pack/plugins/banners @elastic/appex-sharedux diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 71ab26400f496..ea31863576115 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -690,6 +690,10 @@ the infrastructure monitoring use-case within Kibana. using the CURL scripts in the scripts folder. +|{kib-repo}blob/{branch}/x-pack/plugins/ai_infra/llm_tasks/README.md[llmTasks] +|This plugin contains various LLM tasks. + + |{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/logs_data_access/README.md[logsDataAccess] |Exposes services to access logs data. @@ -767,6 +771,10 @@ Elastic. |This plugin helps users learn how to use the Painless scripting language. +|{kib-repo}blob/{branch}/x-pack/plugins/ai_infra/product_doc_base/README.md[productDocBase] +|This plugin contains the product documentation base service. + + |{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/profiling/README.md[profiling] |Universal Profiling provides fleet-wide, whole-system, continuous profiling with zero instrumentation. Get a comprehensive understanding of what lines of code are consuming compute resources throughout your entire fleet by visualizing your data in Kibana using the flamegraph, stacktraces, and top functions views. diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 1ac40bcc7764a..ef12f4303c1b4 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -148,6 +148,9 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | Creating trained model. | `failure` | Failed to create trained model. +.1+| `product_documentation_create` +| `unknown` | User requested to install the product documentation for use in AI Assistants. + 3+a| ====== Type: change @@ -334,6 +337,9 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | Updating trained model deployment. | `failure` | Failed to update trained model deployment. +.1+| `product_documentation_update` +| `unknown` | User requested to update the product documentation for use in AI Assistants. + 3+a| ====== Type: deletion @@ -425,6 +431,9 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | Deleting trained model. | `failure` | Failed to delete trained model. +.1+| `product_documentation_delete` +| `unknown` | User requested to delete the product documentation for use in AI Assistants. + 3+a| ====== Type: access diff --git a/package.json b/package.json index ebbf26f25002c..b67d4b90fdf95 100644 --- a/package.json +++ b/package.json @@ -617,6 +617,7 @@ "@kbn/licensing-plugin": "link:x-pack/plugins/licensing", "@kbn/links-plugin": "link:src/plugins/links", "@kbn/lists-plugin": "link:x-pack/plugins/lists", + "@kbn/llm-tasks-plugin": "link:x-pack/plugins/ai_infra/llm_tasks", "@kbn/locator-examples-plugin": "link:examples/locator_examples", "@kbn/locator-explorer-plugin": "link:examples/locator_explorer", "@kbn/logging": "link:packages/kbn-logging", @@ -721,6 +722,8 @@ "@kbn/presentation-panel-plugin": "link:src/plugins/presentation_panel", "@kbn/presentation-publishing": "link:packages/presentation/presentation_publishing", "@kbn/presentation-util-plugin": "link:src/plugins/presentation_util", + "@kbn/product-doc-base-plugin": "link:x-pack/plugins/ai_infra/product_doc_base", + "@kbn/product-doc-common": "link:x-pack/packages/ai-infra/product-doc-common", "@kbn/profiling-data-access-plugin": "link:x-pack/plugins/observability_solution/profiling_data_access", "@kbn/profiling-plugin": "link:x-pack/plugins/observability_solution/profiling", "@kbn/profiling-utils": "link:packages/kbn-profiling-utils", diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index a5642cee10958..020b9a97753b4 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -855,6 +855,13 @@ "policy-settings-protection-updates-note": [ "note" ], + "product-doc-install-status": [ + "index_name", + "installation_status", + "last_installation_date", + "product_name", + "product_version" + ], "query": [ "description", "title", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 61f680509c133..2409b7578da84 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -2841,6 +2841,26 @@ } } }, + "product-doc-install-status": { + "dynamic": false, + "properties": { + "index_name": { + "type": "keyword" + }, + "installation_status": { + "type": "keyword" + }, + "last_installation_date": { + "type": "date" + }, + "product_name": { + "type": "keyword" + }, + "product_version": { + "type": "keyword" + } + } + }, "query": { "dynamic": false, "properties": { diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 58424700d9bf6..32a7bc827907e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -124,6 +124,7 @@ pageLoadAssetSize: painlessLab: 179748 presentationPanel: 55463 presentationUtil: 58834 + productDocBase: 22500 profiling: 36694 remoteClusters: 51327 reporting: 58600 diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 28a1e8e1eb538..0f186fba94b54 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -145,6 +145,7 @@ describe('checking migration metadata changes on all registered SO types', () => "osquery-pack-asset": "cd140bc2e4b092e93692b587bf6e38051ef94c75", "osquery-saved-query": "6095e288750aa3164dfe186c74bc5195c2bf2bd4", "policy-settings-protection-updates-note": "33924bb246f9e5bcb876109cc83e3c7a28308352", + "product-doc-install-status": "ca6e96840228e4cc2f11bae24a0797f4f7238c8c", "query": "501bece68f26fe561286a488eabb1a8ab12f1137", "risk-engine-configuration": "bab237d09c2e7189dddddcb1b28f19af69755efb", "rules-settings": "ba57ef1881b3dcbf48fbfb28902d8f74442190b2", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index ba06073e454a9..3ceba522d08cb 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -115,6 +115,7 @@ const previouslyRegisteredTypes = [ 'osquery-usage-metric', 'osquery-manager-usage-metric', 'policy-settings-protection-updates-note', + 'product-doc-install-status', 'query', 'rules-settings', 'sample-data-telemetry', diff --git a/tsconfig.base.json b/tsconfig.base.json index 26fe060916a92..3e1d80208f5b4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1148,6 +1148,8 @@ "@kbn/lint-ts-projects-cli/*": ["packages/kbn-lint-ts-projects-cli/*"], "@kbn/lists-plugin": ["x-pack/plugins/lists"], "@kbn/lists-plugin/*": ["x-pack/plugins/lists/*"], + "@kbn/llm-tasks-plugin": ["x-pack/plugins/ai_infra/llm_tasks"], + "@kbn/llm-tasks-plugin/*": ["x-pack/plugins/ai_infra/llm_tasks/*"], "@kbn/locator-examples-plugin": ["examples/locator_examples"], "@kbn/locator-examples-plugin/*": ["examples/locator_examples/*"], "@kbn/locator-explorer-plugin": ["examples/locator_explorer"], @@ -1390,6 +1392,10 @@ "@kbn/presentation-util-plugin/*": ["src/plugins/presentation_util/*"], "@kbn/product-doc-artifact-builder": ["x-pack/packages/ai-infra/product-doc-artifact-builder"], "@kbn/product-doc-artifact-builder/*": ["x-pack/packages/ai-infra/product-doc-artifact-builder/*"], + "@kbn/product-doc-base-plugin": ["x-pack/plugins/ai_infra/product_doc_base"], + "@kbn/product-doc-base-plugin/*": ["x-pack/plugins/ai_infra/product_doc_base/*"], + "@kbn/product-doc-common": ["x-pack/packages/ai-infra/product-doc-common"], + "@kbn/product-doc-common/*": ["x-pack/packages/ai-infra/product-doc-common/*"], "@kbn/profiling-data-access-plugin": ["x-pack/plugins/observability_solution/profiling_data_access"], "@kbn/profiling-data-access-plugin/*": ["x-pack/plugins/observability_solution/profiling_data_access/*"], "@kbn/profiling-plugin": ["x-pack/plugins/observability_solution/profiling"], diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/README.md b/x-pack/packages/ai-infra/product-doc-artifact-builder/README.md index eb64d53b5b8f7..49949def3e5e7 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/README.md +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/README.md @@ -1,3 +1,49 @@ # @kbn/product-doc-artifact-builder -Script to build the knowledge base artifacts +Script to build the knowledge base artifacts. + +## How to run + +``` +node scripts/build_product_doc_artifacts.js --stack-version {version} --product-name {product} +``` + +### parameters + +#### `stack-version`: + +the stack version to generate the artifacts for. + +#### `product-name`: + +(multi-value) the list of products to generate artifacts for. + +possible values: +- "kibana" +- "elasticsearch" +- "observability" +- "security" + +#### `target-folder`: + +The folder to generate the artifacts in. + +Defaults to `{REPO_ROOT}/build-kb-artifacts`. + +#### `build-folder`: + +The folder to use for temporary files. + +Defaults to `{REPO_ROOT}/build/temp-kb-artifacts` + +#### Cluster infos + +- params for the source cluster: +`sourceClusterUrl` / env.KIBANA_SOURCE_CLUSTER_URL +`sourceClusterUsername` / env.KIBANA_SOURCE_CLUSTER_USERNAME +`sourceClusterPassword` / env.KIBANA_SOURCE_CLUSTER_PASSWORD + +- params for the embedding cluster: +`embeddingClusterUrl` / env.KIBANA_EMBEDDING_CLUSTER_URL +`embeddingClusterUsername` / env.KIBANA_EMBEDDING_CLUSTER_USERNAME +`embeddingClusterPassword` / env.KIBANA_EMBEDDING_CLUSTER_PASSWORD \ No newline at end of file diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/manifest.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/manifest.ts index cbebcdc22981b..a8aa927c5ef1f 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/manifest.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/manifest.ts @@ -5,17 +5,13 @@ * 2.0. */ -export interface ArtifactManifest { - formatVersion: string; - productName: string; - productVersion: string; -} +import type { ArtifactManifest, ProductName } from '@kbn/product-doc-common'; export const getArtifactManifest = ({ productName, stackVersion, }: { - productName: string; + productName: ProductName; stackVersion: string; }): ArtifactManifest => { return { diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/mappings.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/mappings.ts index ae84ae60616a3..979845ec31844 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/mappings.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/mappings.ts @@ -21,10 +21,7 @@ export const getArtifactMappings = (inferenceEndpoint: string): MappingTypeMappi slug: { type: 'keyword' }, url: { type: 'keyword' }, version: { type: 'version' }, - ai_subtitle: { - type: 'semantic_text', - inference_id: inferenceEndpoint, - }, + ai_subtitle: { type: 'text' }, ai_summary: { type: 'semantic_text', inference_id: inferenceEndpoint, diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/product_name.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/product_name.ts index cfcc141323f4f..e4ca33849a527 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/product_name.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/product_name.ts @@ -5,7 +5,34 @@ * 2.0. */ -/** - * The allowed product names, as found in the source's cluster - */ -export const sourceProductNames = ['Kibana', 'Elasticsearch', 'Security', 'Observability']; +import type { ProductName } from '@kbn/product-doc-common'; + +const productNameToSourceNamesMap: Record = { + kibana: ['Kibana'], + elasticsearch: ['Elasticsearch'], + security: ['Security'], + observability: ['Observability'], +}; + +const sourceNameToProductName = Object.entries(productNameToSourceNamesMap).reduce< + Record +>((map, [productName, sourceNames]) => { + sourceNames.forEach((sourceName) => { + map[sourceName] = productName as ProductName; + }); + return map; +}, {}); + +export const getSourceNamesFromProductName = (productName: ProductName): string[] => { + if (!productNameToSourceNamesMap[productName]) { + throw new Error(`Unknown product name: ${productName}`); + } + return productNameToSourceNamesMap[productName]; +}; + +export const getProductNameFromSource = (source: string): ProductName => { + if (!sourceNameToProductName[source]) { + throw new Error(`Unknown source name: ${source}`); + } + return sourceNameToProductName[source]; +}; diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/build_artifacts.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/build_artifacts.ts index bbde3310f8e3a..551f58bc68308 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/build_artifacts.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/build_artifacts.ts @@ -8,6 +8,7 @@ import Path from 'path'; import { Client } from '@elastic/elasticsearch'; import { ToolingLog } from '@kbn/tooling-log'; +import type { ProductName } from '@kbn/product-doc-common'; import { // checkConnectivity, createTargetIndex, @@ -18,6 +19,7 @@ import { createArtifact, cleanupFolders, deleteIndex, + processDocuments, } from './tasks'; import type { TaskConfig } from './types'; @@ -93,7 +95,7 @@ const buildArtifact = async ({ sourceClient, log, }: { - productName: string; + productName: ProductName; stackVersion: string; buildFolder: string; targetFolder: string; @@ -105,7 +107,7 @@ const buildArtifact = async ({ const targetIndex = getTargetIndexName({ productName, stackVersion }); - const documents = await extractDocumentation({ + let documents = await extractDocumentation({ client: sourceClient, index: 'search-docs-1', log, @@ -113,6 +115,8 @@ const buildArtifact = async ({ stackVersion, }); + documents = await processDocuments({ documents, log }); + await createTargetIndex({ client: embeddingClient, indexName: targetIndex, diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/command.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/command.ts index 49af1d158db83..e8d0d9486e331 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/command.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/command.ts @@ -6,19 +6,19 @@ */ import Path from 'path'; -import { REPO_ROOT } from '@kbn/repo-info'; import yargs from 'yargs'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { DocumentationProduct } from '@kbn/product-doc-common'; import type { TaskConfig } from './types'; import { buildArtifacts } from './build_artifacts'; -import { sourceProductNames } from './artifact/product_name'; function options(y: yargs.Argv) { return y .option('productName', { describe: 'name of products to generate documentation for', array: true, - choices: sourceProductNames, - default: ['Kibana'], + choices: Object.values(DocumentationProduct), + default: [DocumentationProduct.kibana], }) .option('stackVersion', { describe: 'The stack version to generate documentation for', diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_artifact.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_artifact.ts index 343099876585a..056887a41a4d2 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_artifact.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_artifact.ts @@ -8,9 +8,9 @@ import Path from 'path'; import AdmZip from 'adm-zip'; import type { ToolingLog } from '@kbn/tooling-log'; +import { getArtifactName, type ProductName } from '@kbn/product-doc-common'; import { getArtifactMappings } from '../artifact/mappings'; import { getArtifactManifest } from '../artifact/manifest'; -import { getArtifactName } from '../artifact/artifact_name'; export const createArtifact = async ({ productName, @@ -21,7 +21,7 @@ export const createArtifact = async ({ }: { buildFolder: string; targetFolder: string; - productName: string; + productName: ProductName; stackVersion: string; log: ToolingLog; }) => { diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_chunk_files.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_chunk_files.ts index 8b0e7323c2886..73cf8f0109228 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_chunk_files.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_chunk_files.ts @@ -10,7 +10,7 @@ import Fs from 'fs/promises'; import type { Client } from '@elastic/elasticsearch'; import type { ToolingLog } from '@kbn/tooling-log'; -const fileSizeLimit = 250_000; +const fileSizeLimit = 500_000; export const createChunkFiles = async ({ index, diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_index.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_index.ts index e4f24725883ab..d26ffc980f3ab 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_index.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/create_index.ts @@ -21,10 +21,7 @@ const mappings: MappingTypeMapping = { slug: { type: 'keyword' }, url: { type: 'keyword' }, version: { type: 'version' }, - ai_subtitle: { - type: 'semantic_text', - inference_id: 'kibana-elser2', - }, + ai_subtitle: { type: 'text' }, ai_summary: { type: 'semantic_text', inference_id: 'kibana-elser2', diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/extract_documentation.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/extract_documentation.ts index f1dd051394bbd..6aa8bb49b0cfd 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/extract_documentation.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/extract_documentation.ts @@ -8,6 +8,8 @@ import type { Client } from '@elastic/elasticsearch'; import type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; import type { ToolingLog } from '@kbn/tooling-log'; +import type { ProductName } from '@kbn/product-doc-common'; +import { getSourceNamesFromProductName, getProductNameFromSource } from '../artifact/product_name'; /** the list of fields to import from the source cluster */ const fields = [ @@ -27,7 +29,7 @@ const fields = [ export interface ExtractedDocument { content_title: string; content_body: string; - product_name: string; + product_name: ProductName; root_type: string; slug: string; url: string; @@ -43,7 +45,7 @@ const convertHit = (hit: SearchHit): ExtractedDocument => { return { content_title: source.content_title, content_body: source.content_body, - product_name: source.product_name, + product_name: getProductNameFromSource(source.product_name), root_type: 'documentation', slug: source.slug, url: source.url, @@ -65,7 +67,7 @@ export const extractDocumentation = async ({ client: Client; index: string; stackVersion: string; - productName: string; + productName: ProductName; log: ToolingLog; }) => { log.info(`Starting to extract documents from source cluster`); @@ -76,7 +78,7 @@ export const extractDocumentation = async ({ query: { bool: { must: [ - { term: { product_name: productName } }, + { terms: { product_name: getSourceNamesFromProductName(productName) } }, { term: { version: stackVersion } }, { exists: { field: 'ai_fields.ai_summary' } }, ], diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/index.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/index.ts index 0c63431362329..ec94e4c135c17 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/index.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/index.ts @@ -10,8 +10,8 @@ export { indexDocuments } from './index_documents'; export { createTargetIndex } from './create_index'; export { installElser } from './install_elser'; export { createChunkFiles } from './create_chunk_files'; -export { performSemanticSearch } from './perform_semantic_search'; export { checkConnectivity } from './check_connectivity'; export { createArtifact } from './create_artifact'; export { cleanupFolders } from './cleanup_folders'; export { deleteIndex } from './delete_index'; +export { processDocuments } from './process_documents'; diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/process_documents.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/process_documents.ts new file mode 100644 index 0000000000000..69141ca167ab4 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/process_documents.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniqBy } from 'lodash'; +import { encode } from 'gpt-tokenizer'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { ExtractedDocument } from './extract_documentation'; + +export const processDocuments = async ({ + documents, + log, +}: { + documents: ExtractedDocument[]; + log: ToolingLog; +}): Promise => { + log.info('Starting processing documents.'); + const initialCount = documents.length; + documents = removeDuplicates(documents); + const noDupCount = documents.length; + log.info(`Removed ${initialCount - noDupCount} duplicates`); + documents.forEach(processDocument); + documents = filterEmptyDocs(documents); + log.info(`Removed ${noDupCount - documents.length} empty documents`); + log.info('Done processing documents.'); + return documents; +}; + +const removeDuplicates = (documents: ExtractedDocument[]): ExtractedDocument[] => { + return uniqBy(documents, (doc) => doc.slug); +}; + +/** + * Filter "this content has moved" or "deleted pages" type of documents, just based on token count. + */ +const filterEmptyDocs = (documents: ExtractedDocument[]): ExtractedDocument[] => { + return documents.filter((doc) => { + const tokenCount = encode(doc.content_body).length; + if (tokenCount < 100) { + return false; + } + return true; + }); +}; + +const processDocument = (document: ExtractedDocument) => { + document.content_body = document.content_body + // remove those "edit" button text that got embedded into titles. + .replaceAll(/([a-zA-Z])edit\n/g, (match) => { + return `${match[0]}\n`; + }) + // limit to 2 consecutive carriage return + .replaceAll(/\n\n+/g, '\n\n'); + + return document; +}; diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/types.ts b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/types.ts index d2acfb5774500..1eb4a4348d218 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/types.ts +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/src/types.ts @@ -5,8 +5,10 @@ * 2.0. */ +import type { ProductName } from '@kbn/product-doc-common'; + export interface TaskConfig { - productNames: string[]; + productNames: ProductName[]; stackVersion: string; buildFolder: string; targetFolder: string; diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/tsconfig.json b/x-pack/packages/ai-infra/product-doc-artifact-builder/tsconfig.json index 508d4c715d0a7..68ff27852c4d1 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/tsconfig.json +++ b/x-pack/packages/ai-infra/product-doc-artifact-builder/tsconfig.json @@ -16,5 +16,6 @@ "kbn_references": [ "@kbn/tooling-log", "@kbn/repo-info", + "@kbn/product-doc-common", ] } diff --git a/x-pack/packages/ai-infra/product-doc-common/README.md b/x-pack/packages/ai-infra/product-doc-common/README.md new file mode 100644 index 0000000000000..ff20c0e0fd0e7 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/README.md @@ -0,0 +1,3 @@ +# @kbn/product-doc-common + +Common types and utilities for the product documentation feature. diff --git a/x-pack/packages/ai-infra/product-doc-common/index.ts b/x-pack/packages/ai-infra/product-doc-common/index.ts new file mode 100644 index 0000000000000..1a96737138991 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/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. + */ + +export { getArtifactName, parseArtifactName } from './src/artifact'; +export { type ArtifactManifest } from './src/manifest'; +export { DocumentationProduct, type ProductName } from './src/product'; +export { isArtifactContentFilePath } from './src/artifact_content'; +export { + productDocIndexPrefix, + productDocIndexPattern, + getProductDocIndexName, +} from './src/indices'; +export type { ProductDocumentationAttributes } from './src/documents'; diff --git a/x-pack/packages/ai-infra/product-doc-common/jest.config.js b/x-pack/packages/ai-infra/product-doc-common/jest.config.js new file mode 100644 index 0000000000000..e6cae43806c8d --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/jest.config.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/x-pack/packages/ai-infra/product-doc-common'], +}; diff --git a/x-pack/packages/ai-infra/product-doc-common/kibana.jsonc b/x-pack/packages/ai-infra/product-doc-common/kibana.jsonc new file mode 100644 index 0000000000000..16336c1fc8e27 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/product-doc-common", + "owner": "@elastic/appex-ai-infra" +} diff --git a/x-pack/packages/ai-infra/product-doc-common/package.json b/x-pack/packages/ai-infra/product-doc-common/package.json new file mode 100644 index 0000000000000..839d411a2efb9 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/product-doc-common", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} \ No newline at end of file diff --git a/x-pack/packages/ai-infra/product-doc-common/src/artifact.test.ts b/x-pack/packages/ai-infra/product-doc-common/src/artifact.test.ts new file mode 100644 index 0000000000000..2b6362dbf4aad --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/artifact.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { getArtifactName, parseArtifactName } from './artifact'; + +describe('getArtifactName', () => { + it('builds the name based on the provided product name and version', () => { + expect( + getArtifactName({ + productName: 'kibana', + productVersion: '8.16', + }) + ).toEqual('kb-product-doc-kibana-8.16.zip'); + }); + + it('excludes the extension when excludeExtension is true', () => { + expect( + getArtifactName({ + productName: 'elasticsearch', + productVersion: '8.17', + excludeExtension: true, + }) + ).toEqual('kb-product-doc-elasticsearch-8.17'); + }); + + it('generates a lowercase name', () => { + expect( + getArtifactName({ + // @ts-expect-error testing + productName: 'ElasticSearch', + productVersion: '8.17', + excludeExtension: true, + }) + ).toEqual('kb-product-doc-elasticsearch-8.17'); + }); +}); + +describe('parseArtifactName', () => { + it('parses an artifact name with extension', () => { + expect(parseArtifactName('kb-product-doc-kibana-8.16.zip')).toEqual({ + productName: 'kibana', + productVersion: '8.16', + }); + }); + + it('parses an artifact name without extension', () => { + expect(parseArtifactName('kb-product-doc-security-8.17')).toEqual({ + productName: 'security', + productVersion: '8.17', + }); + }); + + it('returns undefined if the provided string does not match the artifact name pattern', () => { + expect(parseArtifactName('some-wrong-name')).toEqual(undefined); + }); + + it('returns undefined if the provided string is not strictly lowercase', () => { + expect(parseArtifactName('kb-product-doc-Security-8.17')).toEqual(undefined); + }); +}); diff --git a/x-pack/packages/ai-infra/product-doc-common/src/artifact.ts b/x-pack/packages/ai-infra/product-doc-common/src/artifact.ts new file mode 100644 index 0000000000000..1a6745abd733d --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/artifact.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type ProductName, DocumentationProduct } from './product'; + +// kb-product-doc-elasticsearch-8.15.zip +const artifactNameRegexp = /^kb-product-doc-([a-z]+)-([0-9]+\.[0-9]+)(\.zip)?$/; +const allowedProductNames: ProductName[] = Object.values(DocumentationProduct); + +export const getArtifactName = ({ + productName, + productVersion, + excludeExtension = false, +}: { + productName: ProductName; + productVersion: string; + excludeExtension?: boolean; +}): string => { + const ext = excludeExtension ? '' : '.zip'; + return `kb-product-doc-${productName}-${productVersion}${ext}`.toLowerCase(); +}; + +export const parseArtifactName = (artifactName: string) => { + const match = artifactNameRegexp.exec(artifactName); + if (match) { + const productName = match[1].toLowerCase() as ProductName; + const productVersion = match[2].toLowerCase(); + if (allowedProductNames.includes(productName)) { + return { + productName, + productVersion, + }; + } + } +}; diff --git a/x-pack/packages/ai-infra/product-doc-common/src/artifact_content.test.ts b/x-pack/packages/ai-infra/product-doc-common/src/artifact_content.test.ts new file mode 100644 index 0000000000000..3f97aaf94f880 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/artifact_content.test.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 { isArtifactContentFilePath } from './artifact_content'; + +describe('isArtifactContentFilePath', () => { + it('returns true for filenames matching the pattern', () => { + expect(isArtifactContentFilePath('content/content-0.ndjson')).toEqual(true); + expect(isArtifactContentFilePath('content/content-007.ndjson')).toEqual(true); + expect(isArtifactContentFilePath('content/content-9042.ndjson')).toEqual(true); + }); + + it('returns false for filenames not matching the pattern', () => { + expect(isArtifactContentFilePath('content-0.ndjson')).toEqual(false); + expect(isArtifactContentFilePath('content/content-0')).toEqual(false); + expect(isArtifactContentFilePath('content/content.ndjson')).toEqual(false); + expect(isArtifactContentFilePath('content/content-9042.json')).toEqual(false); + }); +}); diff --git a/x-pack/packages/ai-infra/product-doc-common/src/artifact_content.ts b/x-pack/packages/ai-infra/product-doc-common/src/artifact_content.ts new file mode 100644 index 0000000000000..757e6664bb588 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/artifact_content.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const contentFileRegexp = /^content\/content-[0-9]+\.ndjson$/; + +export const isArtifactContentFilePath = (path: string): boolean => { + return contentFileRegexp.test(path); +}; diff --git a/x-pack/packages/ai-infra/product-doc-common/src/documents.ts b/x-pack/packages/ai-infra/product-doc-common/src/documents.ts new file mode 100644 index 0000000000000..ef81b3d6411cc --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/documents.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ProductName } from './product'; + +// don't need to define the other props +interface SemanticTextField { + text: string; +} + +interface SemanticTextArrayField { + text: string[]; +} + +export interface ProductDocumentationAttributes { + content_title: string; + content_body: SemanticTextField; + product_name: ProductName; + root_type: string; + slug: string; + url: string; + version: string; + ai_subtitle: string; + ai_summary: SemanticTextField; + ai_questions_answered: SemanticTextArrayField; + ai_tags: string[]; +} diff --git a/x-pack/packages/ai-infra/product-doc-common/src/indices.ts b/x-pack/packages/ai-infra/product-doc-common/src/indices.ts new file mode 100644 index 0000000000000..b48cacf79fd23 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/indices.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 type { ProductName } from './product'; + +export const productDocIndexPrefix = '.kibana-ai-product-doc'; +export const productDocIndexPattern = `${productDocIndexPrefix}-*`; + +export const getProductDocIndexName = (productName: ProductName): string => { + return `${productDocIndexPrefix}-${productName.toLowerCase()}`; +}; diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/artifact_name.ts b/x-pack/packages/ai-infra/product-doc-common/src/manifest.ts similarity index 59% rename from x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/artifact_name.ts rename to x-pack/packages/ai-infra/product-doc-common/src/manifest.ts index 678b17088c7b4..6c246cf58fd5f 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/artifact/artifact_name.ts +++ b/x-pack/packages/ai-infra/product-doc-common/src/manifest.ts @@ -5,12 +5,10 @@ * 2.0. */ -export const getArtifactName = ({ - productName, - productVersion, -}: { - productName: string; +import type { ProductName } from './product'; + +export interface ArtifactManifest { + formatVersion: string; + productName: ProductName; productVersion: string; -}): string => { - return `kibana-kb-${productName}-${productVersion}.zip`.toLowerCase(); -}; +} diff --git a/x-pack/packages/ai-infra/product-doc-common/src/product.ts b/x-pack/packages/ai-infra/product-doc-common/src/product.ts new file mode 100644 index 0000000000000..417033f5083ec --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/src/product.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. + */ + +export enum DocumentationProduct { + kibana = 'kibana', + elasticsearch = 'elasticsearch', + observability = 'observability', + security = 'security', +} + +export type ProductName = keyof typeof DocumentationProduct; diff --git a/x-pack/packages/ai-infra/product-doc-common/tsconfig.json b/x-pack/packages/ai-infra/product-doc-common/tsconfig.json new file mode 100644 index 0000000000000..0d78dace105e1 --- /dev/null +++ b/x-pack/packages/ai-infra/product-doc-common/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/x-pack/plugins/ai_infra/llm_tasks/README.md b/x-pack/plugins/ai_infra/llm_tasks/README.md new file mode 100644 index 0000000000000..e019d456cd65a --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/README.md @@ -0,0 +1,45 @@ +# LLM Tasks plugin + +This plugin contains various LLM tasks. + +## Retrieve documentation + +This task allows to retrieve documents from our Elastic product documentation. + +The task depends on the `product-doc-base` plugin, as this dependency is used +to install and manage the product documentation. + +### Checking if the task is available + +A `retrieveDocumentationAvailable` API is exposed from the start contract, that +should be used to assert that the `retrieve_doc` task can be used in the current +context. + +That API receive the inbound request as parameter. + +Example: +```ts +if (await llmTasksStart.retrieveDocumentationAvailable({ request })) { + // task is available +} else { + // task is not available +} +``` + +### Executing the task + +The task is executed as an API of the plugin's start contract, and can be invoked +as any other lifecycle API would. + +Example: +```ts +const result = await llmTasksStart.retrieveDocumentation({ + searchTerm: "How to create a space in Kibana?", + request, + connectorId: 'my-connector-id', +}); + +const { success, documents } = result; +``` + +The exhaustive list of options for the task is available on the `RetrieveDocumentationParams` type's TS doc. diff --git a/x-pack/plugins/ai_infra/llm_tasks/jest.config.js b/x-pack/plugins/ai_infra/llm_tasks/jest.config.js new file mode 100644 index 0000000000000..2a6206d4304b9 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/plugins/ai_infra/llm_tasks/server'], + setupFiles: [], + collectCoverage: true, + collectCoverageFrom: [ + '/x-pack/plugins/ai_infra/llm_tasks/{public,server,common}/**/*.{js,ts,tsx}', + ], + + coverageReporters: ['html'], +}; diff --git a/x-pack/plugins/ai_infra/llm_tasks/kibana.jsonc b/x-pack/plugins/ai_infra/llm_tasks/kibana.jsonc new file mode 100644 index 0000000000000..1ef211d01210e --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/kibana.jsonc @@ -0,0 +1,15 @@ +{ + "type": "plugin", + "id": "@kbn/llm-tasks-plugin", + "owner": "@elastic/appex-ai-infra", + "plugin": { + "id": "llmTasks", + "server": true, + "browser": false, + "configPath": ["xpack", "llmTasks"], + "requiredPlugins": ["inference", "productDocBase"], + "requiredBundles": [], + "optionalPlugins": [], + "extraPublicDirs": [] + } +} diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/config.ts b/x-pack/plugins/ai_infra/llm_tasks/server/config.ts new file mode 100644 index 0000000000000..c509af8bda64b --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/config.ts @@ -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 { schema, type TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from '@kbn/core/server'; + +const configSchema = schema.object({}); + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: {}, +}; + +export type LlmTasksConfig = TypeOf; diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/index.ts b/x-pack/plugins/ai_infra/llm_tasks/server/index.ts new file mode 100644 index 0000000000000..1b18426dc2c34 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/index.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 type { PluginInitializer, PluginInitializerContext } from '@kbn/core/server'; +import type { LlmTasksConfig } from './config'; +import type { + LlmTasksPluginSetup, + LlmTasksPluginStart, + PluginSetupDependencies, + PluginStartDependencies, +} from './types'; +import { LlmTasksPlugin } from './plugin'; + +export { config } from './config'; + +export type { LlmTasksPluginSetup, LlmTasksPluginStart }; + +export const plugin: PluginInitializer< + LlmTasksPluginSetup, + LlmTasksPluginStart, + PluginSetupDependencies, + PluginStartDependencies +> = async (pluginInitializerContext: PluginInitializerContext) => + new LlmTasksPlugin(pluginInitializerContext); diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/plugin.ts b/x-pack/plugins/ai_infra/llm_tasks/server/plugin.ts new file mode 100644 index 0000000000000..d10c495ece159 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/plugin.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 type { Logger } from '@kbn/logging'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { LlmTasksConfig } from './config'; +import type { + LlmTasksPluginSetup, + LlmTasksPluginStart, + PluginSetupDependencies, + PluginStartDependencies, +} from './types'; +import { retrieveDocumentation } from './tasks'; + +export class LlmTasksPlugin + implements + Plugin< + LlmTasksPluginSetup, + LlmTasksPluginStart, + PluginSetupDependencies, + PluginStartDependencies + > +{ + private logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + setup( + coreSetup: CoreSetup, + setupDependencies: PluginSetupDependencies + ): LlmTasksPluginSetup { + return {}; + } + + start(core: CoreStart, startDependencies: PluginStartDependencies): LlmTasksPluginStart { + const { inference, productDocBase } = startDependencies; + return { + retrieveDocumentationAvailable: async () => { + const docBaseStatus = await startDependencies.productDocBase.management.getStatus(); + return docBaseStatus.status === 'installed'; + }, + retrieveDocumentation: (options) => { + return retrieveDocumentation({ + outputAPI: inference.getClient({ request: options.request }).output, + searchDocAPI: productDocBase.search, + logger: this.logger.get('tasks.retrieve-documentation'), + })(options); + }, + }; + } +} diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/tasks/index.ts b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/index.ts new file mode 100644 index 0000000000000..41d3911823449 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/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 { retrieveDocumentation } from './retrieve_documentation'; diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/index.ts b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/index.ts new file mode 100644 index 0000000000000..22bf0745bd77f --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { retrieveDocumentation } from './retrieve_documentation'; +export type { + RetrieveDocumentationAPI, + RetrieveDocumentationResult, + RetrieveDocumentationParams, +} from './types'; diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.test.ts b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.test.ts new file mode 100644 index 0000000000000..5722b73ca039c --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServerMock } from '@kbn/core/server/mocks'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import type { DocSearchResult } from '@kbn/product-doc-base-plugin/server/services/search'; + +import { retrieveDocumentation } from './retrieve_documentation'; +import { truncate, count as countTokens } from '../../utils/tokens'; +jest.mock('../../utils/tokens'); +const truncateMock = truncate as jest.MockedFn; +const countTokensMock = countTokens as jest.MockedFn; + +import { summarizeDocument } from './summarize_document'; +jest.mock('./summarize_document'); +const summarizeDocumentMock = summarizeDocument as jest.MockedFn; + +describe('retrieveDocumentation', () => { + let logger: MockedLogger; + let request: ReturnType; + let outputAPI: jest.Mock; + let searchDocAPI: jest.Mock; + let retrieve: ReturnType; + + const createResult = (parts: Partial = {}): DocSearchResult => { + return { + title: 'title', + content: 'content', + url: 'url', + productName: 'kibana', + ...parts, + }; + }; + + beforeEach(() => { + logger = loggerMock.create(); + request = httpServerMock.createKibanaRequest(); + outputAPI = jest.fn(); + searchDocAPI = jest.fn(); + retrieve = retrieveDocumentation({ logger, searchDocAPI, outputAPI }); + }); + + afterEach(() => { + summarizeDocumentMock.mockReset(); + truncateMock.mockReset(); + countTokensMock.mockReset(); + }); + + it('calls the search API with the right parameters', async () => { + searchDocAPI.mockResolvedValue({ results: [] }); + + const result = await retrieve({ + searchTerm: 'What is Kibana?', + products: ['kibana'], + request, + max: 5, + connectorId: '.my-connector', + functionCalling: 'simulated', + }); + + expect(result).toEqual({ + success: true, + documents: [], + }); + + expect(searchDocAPI).toHaveBeenCalledTimes(1); + expect(searchDocAPI).toHaveBeenCalledWith({ + query: 'What is Kibana?', + products: ['kibana'], + max: 5, + }); + }); + + it('reduces the document length using the truncate strategy', async () => { + searchDocAPI.mockResolvedValue({ + results: [ + createResult({ content: 'content-1' }), + createResult({ content: 'content-2' }), + createResult({ content: 'content-3' }), + ], + }); + + countTokensMock.mockImplementation((text) => { + if (text === 'content-2') { + return 150; + } else { + return 50; + } + }); + truncateMock.mockReturnValue('truncated'); + + const result = await retrieve({ + searchTerm: 'What is Kibana?', + request, + connectorId: '.my-connector', + maxDocumentTokens: 100, + tokenReductionStrategy: 'truncate', + }); + + expect(result.documents.length).toEqual(3); + expect(result.documents[0].content).toEqual('content-1'); + expect(result.documents[1].content).toEqual('truncated'); + expect(result.documents[2].content).toEqual('content-3'); + + expect(truncateMock).toHaveBeenCalledTimes(1); + expect(truncateMock).toHaveBeenCalledWith('content-2', 100); + }); + + it('reduces the document length using the summarize strategy', async () => { + searchDocAPI.mockResolvedValue({ + results: [ + createResult({ content: 'content-1' }), + createResult({ content: 'content-2' }), + createResult({ content: 'content-3' }), + ], + }); + + countTokensMock.mockImplementation((text) => { + if (text === 'content-2') { + return 50; + } else { + return 150; + } + }); + truncateMock.mockImplementation((text) => text); + + summarizeDocumentMock.mockImplementation(({ documentContent }) => { + return Promise.resolve({ summary: `${documentContent}-summarized` }); + }); + + const result = await retrieve({ + searchTerm: 'What is Kibana?', + request, + connectorId: '.my-connector', + maxDocumentTokens: 100, + tokenReductionStrategy: 'summarize', + }); + + expect(result.documents.length).toEqual(3); + expect(result.documents[0].content).toEqual('content-1-summarized'); + expect(result.documents[1].content).toEqual('content-2'); + expect(result.documents[2].content).toEqual('content-3-summarized'); + + expect(truncateMock).toHaveBeenCalledTimes(2); + expect(truncateMock).toHaveBeenCalledWith('content-1-summarized', 100); + expect(truncateMock).toHaveBeenCalledWith('content-3-summarized', 100); + }); + + it('logs an error and return an empty list of docs in case of error', async () => { + searchDocAPI.mockResolvedValue({ + results: [createResult({ content: 'content-1' })], + }); + countTokensMock.mockImplementation(() => { + return 150; + }); + summarizeDocumentMock.mockImplementation(() => { + throw new Error('woups'); + }); + + const result = await retrieve({ + searchTerm: 'What is Kibana?', + request, + connectorId: '.my-connector', + maxDocumentTokens: 100, + tokenReductionStrategy: 'summarize', + }); + + expect(result).toEqual({ + success: false, + documents: [], + }); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error retrieving documentation') + ); + }); +}); diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.ts b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.ts new file mode 100644 index 0000000000000..96f966e483601 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/retrieve_documentation.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 type { Logger } from '@kbn/logging'; +import type { OutputAPI } from '@kbn/inference-common'; +import type { ProductDocSearchAPI } from '@kbn/product-doc-base-plugin/server'; +import { truncate, count as countTokens } from '../../utils/tokens'; +import type { RetrieveDocumentationAPI } from './types'; +import { summarizeDocument } from './summarize_document'; + +const MAX_DOCUMENTS_DEFAULT = 3; +const MAX_TOKENS_DEFAULT = 1000; + +export const retrieveDocumentation = + ({ + outputAPI, + searchDocAPI, + logger: log, + }: { + outputAPI: OutputAPI; + searchDocAPI: ProductDocSearchAPI; + logger: Logger; + }): RetrieveDocumentationAPI => + async ({ + searchTerm, + connectorId, + products, + functionCalling, + max = MAX_DOCUMENTS_DEFAULT, + maxDocumentTokens = MAX_TOKENS_DEFAULT, + tokenReductionStrategy = 'summarize', + }) => { + try { + const { results } = await searchDocAPI({ query: searchTerm, products, max }); + + log.debug(`searching with term=[${searchTerm}] returned ${results.length} documents`); + + const processedDocuments = await Promise.all( + results.map(async (document) => { + const tokenCount = countTokens(document.content); + const docHasTooManyTokens = tokenCount >= maxDocumentTokens; + log.debug( + `processing doc [${document.url}] - tokens : [${tokenCount}] - tooManyTokens: [${docHasTooManyTokens}]` + ); + + let content = document.content; + if (docHasTooManyTokens) { + if (tokenReductionStrategy === 'summarize') { + const extractResponse = await summarizeDocument({ + searchTerm, + documentContent: document.content, + outputAPI, + connectorId, + functionCalling, + }); + content = truncate(extractResponse.summary, maxDocumentTokens); + } else { + content = truncate(document.content, maxDocumentTokens); + } + } + + log.debug(`done processing document [${document.url}]`); + return { + title: document.title, + url: document.url, + content, + }; + }) + ); + + log.debug(() => { + const docsAsJson = JSON.stringify(processedDocuments); + return `searching with term=[${searchTerm}] - results: ${docsAsJson}`; + }); + + return { + success: true, + documents: processedDocuments.filter((doc) => doc.content.length > 0), + }; + } catch (e) { + log.error(`Error retrieving documentation: ${e.message}. Returning empty results.`); + return { success: false, documents: [] }; + } + }; diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/summarize_document.ts b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/summarize_document.ts new file mode 100644 index 0000000000000..815cbc94d08f8 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/summarize_document.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 type { ToolSchema, FunctionCallingMode, OutputAPI } from '@kbn/inference-common'; + +const summarizeDocumentSchema = { + type: 'object', + properties: { + useful: { + type: 'boolean', + description: `Whether the provided document has any useful information related to the user's query.`, + }, + summary: { + type: 'string', + description: `The condensed version of the document that can be used to answer the question. Can be empty.`, + }, + }, + required: ['useful'], +} as const satisfies ToolSchema; + +interface SummarizeDocumentResponse { + summary: string; +} + +export const summarizeDocument = async ({ + searchTerm, + documentContent, + connectorId, + outputAPI, + functionCalling, +}: { + searchTerm: string; + documentContent: string; + outputAPI: OutputAPI; + connectorId: string; + functionCalling?: FunctionCallingMode; +}): Promise => { + const result = await outputAPI({ + id: 'summarize_document', + connectorId, + functionCalling, + system: `You are an helpful Elastic assistant, and your current task is to help answer the user's question. + + Given a question and a document, please provide a condensed version of the document that can be used to answer the question. + - Limit the length of the output to 500 words. + - Try to include all relevant information that could be used to answer the question. If this + can't be done within the 500 words limit, then only include the most relevant information related to the question. + - If you think the document isn't relevant at all to answer the question, just return an empty text`, + input: ` + ## User question + + ${searchTerm} + + ## Document + + ${documentContent} + `, + schema: summarizeDocumentSchema, + }); + return { + summary: result.output.summary ?? '', + }; +}; diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/types.ts b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/types.ts new file mode 100644 index 0000000000000..1e0637fcd344c --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/tasks/retrieve_documentation/types.ts @@ -0,0 +1,72 @@ +/* + * 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/server'; +import type { FunctionCallingMode } from '@kbn/inference-common'; +import type { ProductName } from '@kbn/product-doc-common'; + +/** + * Parameters for {@link RetrieveDocumentationAPI} + */ +export interface RetrieveDocumentationParams { + /** + * The search term to perform semantic text with. + * E.g. "What is Kibana Lens?" + */ + searchTerm: string; + /** + * Maximum number of documents to return. + * Defaults to 3. + */ + max?: number; + /** + * Optional list of products to restrict the search to. + */ + products?: ProductName[]; + /** + * The maximum number of tokens to return *per document*. + * Documents exceeding this limit will go through token reduction. + * + * Defaults to `1000`. + */ + maxDocumentTokens?: number; + /** + * The token reduction strategy to apply for documents exceeding max token count. + * - truncate: Will keep the N first tokens + * - summarize: Will call the LLM asking to generate a contextualized summary of the document + * + * Overall, `summarize` is way more efficient, but significantly slower, given that an additional + * LLM call will be performed. + * + * Defaults to `summarize` + */ + tokenReductionStrategy?: 'truncate' | 'summarize'; + /** + * The request that initiated the task. + */ + request: KibanaRequest; + /** + * Id of the LLM connector to use for the task. + */ + connectorId: string; + functionCalling?: FunctionCallingMode; +} + +export interface RetrievedDocument { + title: string; + url: string; + content: string; +} + +export interface RetrieveDocumentationResult { + success: boolean; + documents: RetrievedDocument[]; +} + +export type RetrieveDocumentationAPI = ( + options: RetrieveDocumentationParams +) => Promise; diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/types.ts b/x-pack/plugins/ai_infra/llm_tasks/server/types.ts new file mode 100644 index 0000000000000..d550e4398b509 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/types.ts @@ -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 { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { ProductDocBaseStartContract } from '@kbn/product-doc-base-plugin/server'; +import type { RetrieveDocumentationAPI } from './tasks/retrieve_documentation'; + +/* eslint-disable @typescript-eslint/no-empty-interface*/ + +export interface PluginSetupDependencies {} + +export interface PluginStartDependencies { + inference: InferenceServerStart; + productDocBase: ProductDocBaseStartContract; +} + +/** + * Describes public llmTasks plugin contract returned at the `setup` stage. + */ +export interface LlmTasksPluginSetup {} + +/** + * Describes public llmTasks plugin contract returned at the `start` stage. + */ +export interface LlmTasksPluginStart { + /** + * Checks if all prerequisites to use the `retrieveDocumentation` task + * are respected. Can be used to check if the task can be registered + * as LLM tool for example. + */ + retrieveDocumentationAvailable: () => Promise; + /** + * Perform the `retrieveDocumentation` task. + * + * @see RetrieveDocumentationAPI + */ + retrieveDocumentation: RetrieveDocumentationAPI; +} diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.test.ts b/x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.test.ts new file mode 100644 index 0000000000000..dce97eaea9b75 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.test.ts @@ -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 { count, truncate } from './tokens'; + +describe('count', () => { + it('returns the token count of a given text', () => { + expect(count('some short sentence')).toBeGreaterThan(1); + }); +}); + +describe('truncate', () => { + it('truncates text that exceed the specified maximum token count', () => { + const text = 'some sentence that is likely longer than 5 tokens.'; + const output = truncate(text, 5); + expect(output.length).toBeLessThan(text.length); + }); + it('keeps text with a smaller amount of tokens unchanged', () => { + const text = 'some sentence that is likely less than 100 tokens.'; + const output = truncate(text, 100); + expect(output.length).toEqual(text.length); + }); +}); diff --git a/x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.ts b/x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.ts new file mode 100644 index 0000000000000..cb469144255b7 --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/server/utils/tokens.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 { encode, decode } from 'gpt-tokenizer'; + +export const count = (text: string): number => { + return encode(text).length; +}; + +export const truncate = (text: string, maxTokens: number): string => { + const encoded = encode(text); + if (encoded.length > maxTokens) { + const truncated = encoded.slice(0, maxTokens); + return decode(truncated); + } + return text; +}; diff --git a/x-pack/plugins/ai_infra/llm_tasks/tsconfig.json b/x-pack/plugins/ai_infra/llm_tasks/tsconfig.json new file mode 100644 index 0000000000000..03b87827d941a --- /dev/null +++ b/x-pack/plugins/ai_infra/llm_tasks/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "../../../../typings/**/*", + "common/**/*", + "public/**/*", + "typings/**/*", + "public/**/*.json", + "server/**/*", + "scripts/**/*", + ".storybook/**/*" + ], + "exclude": ["target/**/*", ".storybook/**/*.js"], + "kbn_references": [ + "@kbn/core", + "@kbn/logging", + "@kbn/config-schema", + "@kbn/product-doc-common", + "@kbn/inference-plugin", + "@kbn/product-doc-base-plugin", + "@kbn/logging-mocks", + "@kbn/inference-common", + ] +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/README.md b/x-pack/plugins/ai_infra/product_doc_base/README.md new file mode 100644 index 0000000000000..0ff6c34dd2785 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/README.md @@ -0,0 +1,3 @@ +# Product documentation base plugin + +This plugin contains the product documentation base service. diff --git a/x-pack/plugins/ai_infra/product_doc_base/common/consts.ts b/x-pack/plugins/ai_infra/product_doc_base/common/consts.ts new file mode 100644 index 0000000000000..1622df5ed865c --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/common/consts.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 const productDocInstallStatusSavedObjectTypeName = 'product-doc-install-status'; + +/** + * The id of the inference endpoint we're creating for our product doc indices. + * Could be replaced with the default elser 2 endpoint once the default endpoint feature is available. + */ +export const internalElserInferenceId = 'kibana-internal-elser2'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/common/http_api/installation.ts b/x-pack/plugins/ai_infra/product_doc_base/common/http_api/installation.ts new file mode 100644 index 0000000000000..0237bd2c3b488 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/common/http_api/installation.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ProductName } from '@kbn/product-doc-common'; +import type { ProductInstallState, InstallationStatus } from '../install_status'; + +export const INSTALLATION_STATUS_API_PATH = '/internal/product_doc_base/status'; +export const INSTALL_ALL_API_PATH = '/internal/product_doc_base/install'; +export const UNINSTALL_ALL_API_PATH = '/internal/product_doc_base/uninstall'; + +export interface InstallationStatusResponse { + overall: InstallationStatus; + perProducts: Record; +} + +export interface PerformInstallResponse { + installed: boolean; +} + +export interface UninstallResponse { + success: boolean; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/common/install_status.ts b/x-pack/plugins/ai_infra/product_doc_base/common/install_status.ts new file mode 100644 index 0000000000000..81102d43c1ff3 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/common/install_status.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 type { ProductName } from '@kbn/product-doc-common'; + +export type InstallationStatus = 'installed' | 'uninstalled' | 'installing' | 'error'; + +/** + * DTO representation of the product doc install status SO + */ +export interface ProductDocInstallStatus { + id: string; + productName: ProductName; + productVersion: string; + installationStatus: InstallationStatus; + lastInstallationDate: Date | undefined; + lastInstallationFailureReason: string | undefined; + indexName?: string; +} + +export interface ProductInstallState { + status: InstallationStatus; + version?: string; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/jest.config.js b/x-pack/plugins/ai_infra/product_doc_base/jest.config.js new file mode 100644 index 0000000000000..fc06be251a6f7 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: [ + '/x-pack/plugins/ai_infra/product_doc_base/public', + '/x-pack/plugins/ai_infra/product_doc_base/server', + '/x-pack/plugins/ai_infra/product_doc_base/common', + ], + setupFiles: [], + collectCoverage: true, + collectCoverageFrom: [ + '/x-pack/plugins/ai_infra/product_doc_base/{public,server,common}/**/*.{js,ts,tsx}', + ], + + coverageReporters: ['html'], +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/kibana.jsonc b/x-pack/plugins/ai_infra/product_doc_base/kibana.jsonc new file mode 100644 index 0000000000000..268b4a70c9921 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/kibana.jsonc @@ -0,0 +1,15 @@ +{ + "type": "plugin", + "id": "@kbn/product-doc-base-plugin", + "owner": "@elastic/appex-ai-infra", + "plugin": { + "id": "productDocBase", + "server": true, + "browser": true, + "configPath": ["xpack", "productDocBase"], + "requiredPlugins": ["licensing", "taskManager"], + "requiredBundles": [], + "optionalPlugins": [], + "extraPublicDirs": [] + } +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/index.ts b/x-pack/plugins/ai_infra/product_doc_base/public/index.ts new file mode 100644 index 0000000000000..b5ccbf029a73e --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; +import { ProductDocBasePlugin } from './plugin'; +import type { + ProductDocBasePluginSetup, + ProductDocBasePluginStart, + PluginSetupDependencies, + PluginStartDependencies, + PublicPluginConfig, +} from './types'; + +export type { ProductDocBasePluginSetup, ProductDocBasePluginStart }; + +export const plugin: PluginInitializer< + ProductDocBasePluginSetup, + ProductDocBasePluginStart, + PluginSetupDependencies, + PluginStartDependencies +> = (pluginInitializerContext: PluginInitializerContext) => + new ProductDocBasePlugin(pluginInitializerContext); diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/plugin.tsx b/x-pack/plugins/ai_infra/product_doc_base/public/plugin.tsx new file mode 100644 index 0000000000000..6f2c989b6e45d --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/plugin.tsx @@ -0,0 +1,51 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { Logger } from '@kbn/logging'; +import type { + PublicPluginConfig, + ProductDocBasePluginSetup, + ProductDocBasePluginStart, + PluginSetupDependencies, + PluginStartDependencies, +} from './types'; +import { InstallationService } from './services/installation'; + +export class ProductDocBasePlugin + implements + Plugin< + ProductDocBasePluginSetup, + ProductDocBasePluginStart, + PluginSetupDependencies, + PluginStartDependencies + > +{ + logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + setup( + coreSetup: CoreSetup, + pluginsSetup: PluginSetupDependencies + ): ProductDocBasePluginSetup { + return {}; + } + + start(coreStart: CoreStart, pluginsStart: PluginStartDependencies): ProductDocBasePluginStart { + const installationService = new InstallationService({ http: coreStart.http }); + + return { + installation: { + getStatus: () => installationService.getInstallationStatus(), + install: () => installationService.install(), + uninstall: () => installationService.uninstall(), + }, + }; + } +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/index.ts b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/index.ts new file mode 100644 index 0000000000000..2eee8613d77dc --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { InstallationService } from './installation_service'; +export type { InstallationAPI } from './types'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.test.ts b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.test.ts new file mode 100644 index 0000000000000..294aeb99e0fd8 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.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 { httpServiceMock } from '@kbn/core/public/mocks'; +import { InstallationService } from './installation_service'; +import { + INSTALLATION_STATUS_API_PATH, + INSTALL_ALL_API_PATH, + UNINSTALL_ALL_API_PATH, +} from '../../../common/http_api/installation'; + +describe('InstallationService', () => { + let http: ReturnType; + let service: InstallationService; + + beforeEach(() => { + http = httpServiceMock.createSetupContract(); + service = new InstallationService({ http }); + }); + + describe('#getInstallationStatus', () => { + it('calls the endpoint with the right parameters', async () => { + await service.getInstallationStatus(); + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(INSTALLATION_STATUS_API_PATH); + }); + it('returns the value from the server', async () => { + const expected = { stubbed: true }; + http.get.mockResolvedValue(expected); + + const response = await service.getInstallationStatus(); + expect(response).toEqual(expected); + }); + }); + describe('#install', () => { + beforeEach(() => { + http.post.mockResolvedValue({ installed: true }); + }); + + it('calls the endpoint with the right parameters', async () => { + await service.install(); + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith(INSTALL_ALL_API_PATH); + }); + it('returns the value from the server', async () => { + const expected = { installed: true }; + http.post.mockResolvedValue(expected); + + const response = await service.install(); + expect(response).toEqual(expected); + }); + it('throws when the server returns installed: false', async () => { + const expected = { installed: false }; + http.post.mockResolvedValue(expected); + + await expect(service.install()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Installation did not complete successfully"` + ); + }); + }); + describe('#uninstall', () => { + it('calls the endpoint with the right parameters', async () => { + await service.uninstall(); + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith(UNINSTALL_ALL_API_PATH); + }); + it('returns the value from the server', async () => { + const expected = { stubbed: true }; + http.post.mockResolvedValue(expected); + + const response = await service.uninstall(); + expect(response).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.ts b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.ts new file mode 100644 index 0000000000000..ff347f52cb531 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/installation_service.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from '@kbn/core-http-browser'; +import { + INSTALLATION_STATUS_API_PATH, + INSTALL_ALL_API_PATH, + UNINSTALL_ALL_API_PATH, + InstallationStatusResponse, + PerformInstallResponse, + UninstallResponse, +} from '../../../common/http_api/installation'; + +export class InstallationService { + private readonly http: HttpSetup; + + constructor({ http }: { http: HttpSetup }) { + this.http = http; + } + + async getInstallationStatus(): Promise { + return await this.http.get(INSTALLATION_STATUS_API_PATH); + } + + async install(): Promise { + const response = await this.http.post(INSTALL_ALL_API_PATH); + if (!response.installed) { + throw new Error('Installation did not complete successfully'); + } + return response; + } + + async uninstall(): Promise { + return await this.http.post(UNINSTALL_ALL_API_PATH); + } +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/types.ts b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/types.ts new file mode 100644 index 0000000000000..5c01c84b24625 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/services/installation/types.ts @@ -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 type { + InstallationStatusResponse, + PerformInstallResponse, + UninstallResponse, +} from '../../../common/http_api/installation'; + +export interface InstallationAPI { + getStatus(): Promise; + install(): Promise; + uninstall(): Promise; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/public/types.ts b/x-pack/plugins/ai_infra/product_doc_base/public/types.ts new file mode 100644 index 0000000000000..1d06b0e08fa23 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/public/types.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 type { InstallationAPI } from './services/installation'; + +/* eslint-disable @typescript-eslint/no-empty-interface*/ + +export interface PublicPluginConfig {} + +export interface PluginSetupDependencies {} + +export interface PluginStartDependencies {} + +export interface ProductDocBasePluginSetup {} + +export interface ProductDocBasePluginStart { + installation: InstallationAPI; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/config.ts b/x-pack/plugins/ai_infra/product_doc_base/server/config.ts new file mode 100644 index 0000000000000..bd0892d582701 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/config.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, type TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from '@kbn/core/server'; + +const configSchema = schema.object({ + artifactRepositoryUrl: schema.string({ + defaultValue: 'https://kibana-knowledge-base-artifacts.elastic.co', + }), +}); + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: {}, +}; + +export type ProductDocBaseConfig = TypeOf; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/index.ts new file mode 100644 index 0000000000000..805a0f2ea8c41 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/server'; +import type { ProductDocBaseConfig } from './config'; +import type { + ProductDocBaseSetupContract, + ProductDocBaseStartContract, + ProductDocBaseSetupDependencies, + ProductDocBaseStartDependencies, +} from './types'; +import { ProductDocBasePlugin } from './plugin'; + +export { config } from './config'; + +export type { ProductDocBaseSetupContract, ProductDocBaseStartContract }; +export type { SearchApi as ProductDocSearchAPI } from './services/search/types'; + +export const plugin: PluginInitializer< + ProductDocBaseSetupContract, + ProductDocBaseStartContract, + ProductDocBaseSetupDependencies, + ProductDocBaseStartDependencies +> = async (pluginInitializerContext: PluginInitializerContext) => + new ProductDocBasePlugin(pluginInitializerContext); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/plugin.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/plugin.test.ts new file mode 100644 index 0000000000000..bd5d6a720dd71 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/plugin.test.ts @@ -0,0 +1,96 @@ +/* + * 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 { coreMock } from '@kbn/core/server/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { productDocInstallStatusSavedObjectTypeName } from '../common/consts'; +import { ProductDocBasePlugin } from './plugin'; +import { ProductDocBaseSetupDependencies, ProductDocBaseStartDependencies } from './types'; + +jest.mock('./services/package_installer'); +jest.mock('./services/search'); +jest.mock('./services/doc_install_status'); +jest.mock('./routes'); +jest.mock('./tasks'); +import { registerRoutes } from './routes'; +import { PackageInstaller } from './services/package_installer'; +import { registerTaskDefinitions, scheduleEnsureUpToDateTask } from './tasks'; + +const PackageInstallMock = PackageInstaller as jest.Mock; + +describe('ProductDocBasePlugin', () => { + let initContext: ReturnType; + let plugin: ProductDocBasePlugin; + let pluginSetupDeps: ProductDocBaseSetupDependencies; + let pluginStartDeps: ProductDocBaseStartDependencies; + + beforeEach(() => { + initContext = coreMock.createPluginInitializerContext(); + plugin = new ProductDocBasePlugin(initContext); + pluginSetupDeps = { + taskManager: taskManagerMock.createSetup(), + }; + pluginStartDeps = { + licensing: licensingMock.createStart(), + taskManager: taskManagerMock.createStart(), + }; + + PackageInstallMock.mockReturnValue({ ensureUpToDate: jest.fn().mockResolvedValue({}) }); + }); + + afterEach(() => { + (scheduleEnsureUpToDateTask as jest.Mock).mockReset(); + }); + + describe('#setup', () => { + it('register the routes', () => { + plugin.setup(coreMock.createSetup(), pluginSetupDeps); + + expect(registerRoutes).toHaveBeenCalledTimes(1); + }); + it('register the product-doc SO type', () => { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, pluginSetupDeps); + + expect(coreSetup.savedObjects.registerType).toHaveBeenCalledTimes(1); + expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ + name: productDocInstallStatusSavedObjectTypeName, + }) + ); + }); + it('register the task definitions', () => { + plugin.setup(coreMock.createSetup(), pluginSetupDeps); + + expect(registerTaskDefinitions).toHaveBeenCalledTimes(3); + }); + }); + + describe('#start', () => { + it('returns a contract with the expected shape', () => { + plugin.setup(coreMock.createSetup(), pluginSetupDeps); + const startContract = plugin.start(coreMock.createStart(), pluginStartDeps); + expect(startContract).toEqual({ + management: { + getStatus: expect.any(Function), + install: expect.any(Function), + uninstall: expect.any(Function), + update: expect.any(Function), + }, + search: expect.any(Function), + }); + }); + + it('schedules the update task', () => { + plugin.setup(coreMock.createSetup(), pluginSetupDeps); + plugin.start(coreMock.createStart(), pluginStartDeps); + + expect(scheduleEnsureUpToDateTask).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/plugin.ts b/x-pack/plugins/ai_infra/product_doc_base/server/plugin.ts new file mode 100644 index 0000000000000..c8ed100cabb16 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/plugin.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Path from 'path'; +import type { Logger } from '@kbn/logging'; +import { getDataPath } from '@kbn/utils'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import { SavedObjectsClient } from '@kbn/core/server'; +import { productDocInstallStatusSavedObjectTypeName } from '../common/consts'; +import type { ProductDocBaseConfig } from './config'; +import { + ProductDocBaseSetupContract, + ProductDocBaseStartContract, + ProductDocBaseSetupDependencies, + ProductDocBaseStartDependencies, + InternalServices, +} from './types'; +import { productDocInstallStatusSavedObjectType } from './saved_objects'; +import { PackageInstaller } from './services/package_installer'; +import { InferenceEndpointManager } from './services/inference_endpoint'; +import { ProductDocInstallClient } from './services/doc_install_status'; +import { DocumentationManager } from './services/doc_manager'; +import { SearchService } from './services/search'; +import { registerRoutes } from './routes'; +import { registerTaskDefinitions } from './tasks'; + +export class ProductDocBasePlugin + implements + Plugin< + ProductDocBaseSetupContract, + ProductDocBaseStartContract, + ProductDocBaseSetupDependencies, + ProductDocBaseStartDependencies + > +{ + private logger: Logger; + private internalServices?: InternalServices; + + constructor(private readonly context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + setup( + coreSetup: CoreSetup, + { taskManager }: ProductDocBaseSetupDependencies + ): ProductDocBaseSetupContract { + const getServices = () => { + if (!this.internalServices) { + throw new Error('getServices called before #start'); + } + return this.internalServices; + }; + + coreSetup.savedObjects.registerType(productDocInstallStatusSavedObjectType); + + registerTaskDefinitions({ + taskManager, + getServices, + }); + + const router = coreSetup.http.createRouter(); + registerRoutes({ + router, + getServices, + }); + + return {}; + } + + start( + core: CoreStart, + { licensing, taskManager }: ProductDocBaseStartDependencies + ): ProductDocBaseStartContract { + const soClient = new SavedObjectsClient( + core.savedObjects.createInternalRepository([productDocInstallStatusSavedObjectTypeName]) + ); + const productDocClient = new ProductDocInstallClient({ soClient }); + + const endpointManager = new InferenceEndpointManager({ + esClient: core.elasticsearch.client.asInternalUser, + logger: this.logger.get('endpoint-manager'), + }); + + const packageInstaller = new PackageInstaller({ + esClient: core.elasticsearch.client.asInternalUser, + productDocClient, + endpointManager, + kibanaVersion: this.context.env.packageInfo.version, + artifactsFolder: Path.join(getDataPath(), 'ai-kb-artifacts'), + artifactRepositoryUrl: this.context.config.get().artifactRepositoryUrl, + logger: this.logger.get('package-installer'), + }); + + const searchService = new SearchService({ + esClient: core.elasticsearch.client.asInternalUser, + logger: this.logger.get('search-service'), + }); + + const documentationManager = new DocumentationManager({ + logger: this.logger.get('doc-manager'), + docInstallClient: productDocClient, + licensing, + taskManager, + auditService: core.security.audit, + }); + + this.internalServices = { + logger: this.logger, + packageInstaller, + installClient: productDocClient, + documentationManager, + licensing, + taskManager, + }; + + documentationManager.update().catch((err) => { + this.logger.error(`Error scheduling product documentation update task: ${err.message}`); + }); + + return { + management: { + install: documentationManager.install.bind(documentationManager), + update: documentationManager.update.bind(documentationManager), + uninstall: documentationManager.uninstall.bind(documentationManager), + getStatus: documentationManager.getStatus.bind(documentationManager), + }, + search: searchService.search.bind(searchService), + }; + } +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/routes/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/routes/index.ts new file mode 100644 index 0000000000000..66660c199d819 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/routes/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import { registerInstallationRoutes } from './installation'; +import type { InternalServices } from '../types'; + +export const registerRoutes = ({ + router, + getServices, +}: { + router: IRouter; + getServices: () => InternalServices; +}) => { + registerInstallationRoutes({ getServices, router }); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/routes/installation.ts b/x-pack/plugins/ai_infra/product_doc_base/server/routes/installation.ts new file mode 100644 index 0000000000000..dbede9f7d94d3 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/routes/installation.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 type { IRouter } from '@kbn/core/server'; +import { + INSTALLATION_STATUS_API_PATH, + INSTALL_ALL_API_PATH, + UNINSTALL_ALL_API_PATH, + InstallationStatusResponse, + PerformInstallResponse, + UninstallResponse, +} from '../../common/http_api/installation'; +import type { InternalServices } from '../types'; + +export const registerInstallationRoutes = ({ + router, + getServices, +}: { + router: IRouter; + getServices: () => InternalServices; +}) => { + router.get( + { + path: INSTALLATION_STATUS_API_PATH, + validate: false, + options: { + access: 'internal', + security: { + authz: { + requiredPrivileges: ['manage_llm_product_doc'], + }, + }, + }, + }, + async (ctx, req, res) => { + const { installClient, documentationManager } = getServices(); + const installStatus = await installClient.getInstallationStatus(); + const { status: overallStatus } = await documentationManager.getStatus(); + + return res.ok({ + body: { + perProducts: installStatus, + overall: overallStatus, + }, + }); + } + ); + + router.post( + { + path: INSTALL_ALL_API_PATH, + validate: false, + options: { + access: 'internal', + security: { + authz: { + requiredPrivileges: ['manage_llm_product_doc'], + }, + }, + timeout: { idleSocket: 20 * 60 * 1000 }, // install can take time. + }, + }, + async (ctx, req, res) => { + const { documentationManager } = getServices(); + + await documentationManager.install({ + request: req, + force: false, + wait: true, + }); + + // check status after installation in case of failure + const { status } = await documentationManager.getStatus(); + + return res.ok({ + body: { + installed: status === 'installed', + }, + }); + } + ); + + router.post( + { + path: UNINSTALL_ALL_API_PATH, + validate: false, + options: { + access: 'internal', + security: { + authz: { + requiredPrivileges: ['manage_llm_product_doc'], + }, + }, + }, + }, + async (ctx, req, res) => { + const { documentationManager } = getServices(); + + await documentationManager.uninstall({ + request: req, + wait: true, + }); + + return res.ok({ + body: { + success: true, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/index.ts new file mode 100644 index 0000000000000..f87c6d37eb66f --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/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 { + productDocInstallStatusSavedObjectType, + type ProductDocInstallStatusAttributes, +} from './product_doc_install'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/product_doc_install.ts b/x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/product_doc_install.ts new file mode 100644 index 0000000000000..47cf7eb50cdd1 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/saved_objects/product_doc_install.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 type { SavedObjectsType } from '@kbn/core/server'; +import type { ProductName } from '@kbn/product-doc-common'; +import { productDocInstallStatusSavedObjectTypeName } from '../../common/consts'; +import type { InstallationStatus } from '../../common/install_status'; + +/** + * Interface describing the raw attributes of the product doc install SO type. + * Contains more fields than the mappings, which only list + * indexed fields. + */ +export interface ProductDocInstallStatusAttributes { + product_name: ProductName; + product_version: string; + installation_status: InstallationStatus; + last_installation_date?: number; + last_installation_failure_reason?: string; + index_name?: string; +} + +export const productDocInstallStatusSavedObjectType: SavedObjectsType = + { + name: productDocInstallStatusSavedObjectTypeName, + hidden: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + product_name: { type: 'keyword' }, + product_version: { type: 'keyword' }, + installation_status: { type: 'keyword' }, + last_installation_date: { type: 'date' }, + index_name: { type: 'keyword' }, + }, + }, + management: { + importableAndExportable: false, + }, + modelVersions: {}, + }; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/index.ts new file mode 100644 index 0000000000000..d55cb303b1908 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/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 { ProductDocInstallClient } from './product_doc_install_service'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.test.ts new file mode 100644 index 0000000000000..6460d8452dc2b --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { SavedObject } from '@kbn/core/server'; +import type { ProductDocInstallStatusAttributes } from '../../saved_objects'; +import { soToModel } from './model_conversion'; + +const createObj = ( + attrs: ProductDocInstallStatusAttributes +): SavedObject => { + return { + id: 'some-id', + type: 'product-doc-install-status', + attributes: attrs, + references: [], + }; +}; + +describe('soToModel', () => { + it('converts the SO to the expected shape', () => { + const input = createObj({ + product_name: 'kibana', + product_version: '8.16', + installation_status: 'installed', + last_installation_date: 9000, + index_name: '.kibana', + }); + + const output = soToModel(input); + + expect(output).toEqual({ + id: 'some-id', + productName: 'kibana', + productVersion: '8.16', + indexName: '.kibana', + installationStatus: 'installed', + lastInstallationDate: expect.any(Date), + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.ts new file mode 100644 index 0000000000000..cf77bb9222a15 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/model_conversion.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObject } from '@kbn/core/server'; +import type { ProductDocInstallStatus } from '../../../common/install_status'; +import type { ProductDocInstallStatusAttributes } from '../../saved_objects'; + +export const soToModel = ( + so: SavedObject +): ProductDocInstallStatus => { + return { + id: so.id, + productName: so.attributes.product_name, + productVersion: so.attributes.product_version, + installationStatus: so.attributes.installation_status, + indexName: so.attributes.index_name, + lastInstallationDate: so.attributes.last_installation_date + ? new Date(so.attributes.last_installation_date) + : undefined, + lastInstallationFailureReason: so.attributes.last_installation_failure_reason, + }; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.test.ts new file mode 100644 index 0000000000000..81249038a1294 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsFindResult } from '@kbn/core/server'; +import { DocumentationProduct } from '@kbn/product-doc-common'; +import type { ProductDocInstallStatusAttributes as TypeAttributes } from '../../saved_objects'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { ProductDocInstallClient } from './product_doc_install_service'; + +const createObj = (attrs: TypeAttributes): SavedObjectsFindResult => { + return { + id: attrs.product_name, + type: 'type', + references: [], + attributes: attrs, + score: 42, + }; +}; + +describe('ProductDocInstallClient', () => { + let soClient: ReturnType; + let service: ProductDocInstallClient; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + service = new ProductDocInstallClient({ soClient }); + }); + + describe('getInstallationStatus', () => { + it('returns the installation status based on existing entries', async () => { + soClient.find.mockResolvedValue({ + saved_objects: [ + createObj({ + product_name: 'kibana', + product_version: '8.15', + installation_status: 'installed', + }), + createObj({ + product_name: 'elasticsearch', + product_version: '8.15', + installation_status: 'installing', + }), + ], + total: 2, + per_page: 100, + page: 1, + }); + + const installStatus = await service.getInstallationStatus(); + + expect(Object.keys(installStatus).sort()).toEqual(Object.keys(DocumentationProduct).sort()); + expect(installStatus.kibana).toEqual({ + status: 'installed', + version: '8.15', + }); + expect(installStatus.security).toEqual({ + status: 'uninstalled', + }); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.ts new file mode 100644 index 0000000000000..24625ebc51586 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/product_doc_install_service.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 type { SavedObjectsClientContract } from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { ProductName, DocumentationProduct } from '@kbn/product-doc-common'; +import type { ProductInstallState } from '../../../common/install_status'; +import { productDocInstallStatusSavedObjectTypeName as typeName } from '../../../common/consts'; +import type { ProductDocInstallStatusAttributes as TypeAttributes } from '../../saved_objects'; + +export class ProductDocInstallClient { + private soClient: SavedObjectsClientContract; + + constructor({ soClient }: { soClient: SavedObjectsClientContract }) { + this.soClient = soClient; + } + + async getInstallationStatus(): Promise> { + const response = await this.soClient.find({ + type: typeName, + perPage: 100, + }); + + const installStatus = Object.values(DocumentationProduct).reduce((memo, product) => { + memo[product] = { status: 'uninstalled' }; + return memo; + }, {} as Record); + + response.saved_objects.forEach(({ attributes }) => { + installStatus[attributes.product_name as ProductName] = { + status: attributes.installation_status, + version: attributes.product_version, + }; + }); + + return installStatus; + } + + async setInstallationStarted(fields: { productName: ProductName; productVersion: string }) { + const { productName, productVersion } = fields; + const objectId = getObjectIdFromProductName(productName); + const attributes = { + product_name: productName, + product_version: productVersion, + installation_status: 'installing' as const, + last_installation_failure_reason: '', + }; + await this.soClient.update(typeName, objectId, attributes, { + upsert: attributes, + }); + } + + async setInstallationSuccessful(productName: ProductName, indexName: string) { + const objectId = getObjectIdFromProductName(productName); + await this.soClient.update(typeName, objectId, { + installation_status: 'installed', + index_name: indexName, + }); + } + + async setInstallationFailed(productName: ProductName, failureReason: string) { + const objectId = getObjectIdFromProductName(productName); + await this.soClient.update(typeName, objectId, { + installation_status: 'error', + last_installation_failure_reason: failureReason, + }); + } + + async setUninstalled(productName: ProductName) { + const objectId = getObjectIdFromProductName(productName); + try { + await this.soClient.update(typeName, objectId, { + installation_status: 'uninstalled', + last_installation_failure_reason: '', + }); + } catch (e) { + if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { + throw e; + } + } + } +} + +const getObjectIdFromProductName = (productName: ProductName) => + `kb-product-doc-${productName}-status`.toLowerCase(); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/service.mock.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/service.mock.ts new file mode 100644 index 0000000000000..c2a0adbac9f29 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_install_status/service.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. + */ + +import type { ProductDocInstallClient } from './product_doc_install_service'; + +export type InstallClientMock = jest.Mocked; + +const createInstallClientMock = (): InstallClientMock => { + return { + getInstallationStatus: jest.fn(), + setInstallationStarted: jest.fn(), + setInstallationSuccessful: jest.fn(), + setInstallationFailed: jest.fn(), + setUninstalled: jest.fn(), + } as unknown as InstallClientMock; +}; + +export const installClientMock = { + create: createInstallClientMock, +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/check_license.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/check_license.ts new file mode 100644 index 0000000000000..d4af5b7ebdb22 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/check_license.ts @@ -0,0 +1,13 @@ +/* + * 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 { ILicense } from '@kbn/licensing-plugin/server'; + +export const checkLicense = (license: ILicense): boolean => { + const result = license.check('elastic documentation', 'enterprise'); + return result.state === 'valid'; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.test.ts new file mode 100644 index 0000000000000..0be913ee6dd71 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.test.ts @@ -0,0 +1,247 @@ +/* + * 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 { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { securityServiceMock, httpServerMock } from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import type { ProductDocInstallClient } from '../doc_install_status'; +import { DocumentationManager } from './doc_manager'; + +jest.mock('../../tasks'); +import { + scheduleInstallAllTask, + scheduleUninstallAllTask, + scheduleEnsureUpToDateTask, + getTaskStatus, + waitUntilTaskCompleted, +} from '../../tasks'; + +const scheduleInstallAllTaskMock = scheduleInstallAllTask as jest.MockedFn< + typeof scheduleInstallAllTask +>; +const scheduleUninstallAllTaskMock = scheduleUninstallAllTask as jest.MockedFn< + typeof scheduleUninstallAllTask +>; +const scheduleEnsureUpToDateTaskMock = scheduleEnsureUpToDateTask as jest.MockedFn< + typeof scheduleEnsureUpToDateTask +>; +const waitUntilTaskCompletedMock = waitUntilTaskCompleted as jest.MockedFn< + typeof waitUntilTaskCompleted +>; +const getTaskStatusMock = getTaskStatus as jest.MockedFn; + +describe('DocumentationManager', () => { + let logger: MockedLogger; + let taskManager: ReturnType; + let licensing: ReturnType; + let auditService: ReturnType['audit']; + let docInstallClient: jest.Mocked; + + let docManager: DocumentationManager; + + beforeEach(() => { + logger = loggerMock.create(); + taskManager = taskManagerMock.createStart(); + licensing = licensingMock.createStart(); + auditService = securityServiceMock.createStart().audit; + + docInstallClient = { + getInstallationStatus: jest.fn(), + } as unknown as jest.Mocked; + + docManager = new DocumentationManager({ + logger, + taskManager, + licensing, + auditService, + docInstallClient, + }); + }); + + afterEach(() => { + scheduleInstallAllTaskMock.mockReset(); + scheduleUninstallAllTaskMock.mockReset(); + scheduleEnsureUpToDateTaskMock.mockReset(); + waitUntilTaskCompletedMock.mockReset(); + getTaskStatusMock.mockReset(); + }); + + describe('#install', () => { + beforeEach(() => { + licensing.getLicense.mockResolvedValue( + licensingMock.createLicense({ license: { type: 'enterprise' } }) + ); + + getTaskStatusMock.mockResolvedValue('not_scheduled'); + + docInstallClient.getInstallationStatus.mockResolvedValue({ + kibana: { status: 'uninstalled' }, + } as Awaited>); + }); + + it('calls `scheduleInstallAllTask`', async () => { + await docManager.install({}); + + expect(scheduleInstallAllTaskMock).toHaveBeenCalledTimes(1); + expect(scheduleInstallAllTaskMock).toHaveBeenCalledWith({ + taskManager, + logger, + }); + + expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled(); + }); + + it('calls waitUntilTaskCompleted if wait=true', async () => { + await docManager.install({ wait: true }); + + expect(scheduleInstallAllTaskMock).toHaveBeenCalledTimes(1); + expect(waitUntilTaskCompletedMock).toHaveBeenCalledTimes(1); + }); + + it('does not call scheduleInstallAllTask if already installed and not force', async () => { + docInstallClient.getInstallationStatus.mockResolvedValue({ + kibana: { status: 'installed' }, + } as Awaited>); + + await docManager.install({ wait: true }); + + expect(scheduleInstallAllTaskMock).not.toHaveBeenCalled(); + expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled(); + }); + + it('records an audit log when request is provided', async () => { + const request = httpServerMock.createKibanaRequest(); + + const auditLog = auditService.withoutRequest; + auditService.asScoped = jest.fn(() => auditLog); + + await docManager.install({ force: false, wait: false, request }); + + expect(auditLog.log).toHaveBeenCalledTimes(1); + expect(auditLog.log).toHaveBeenCalledWith({ + message: expect.any(String), + event: { + action: 'product_documentation_create', + category: ['database'], + type: ['creation'], + outcome: 'unknown', + }, + }); + }); + + it('throws an error if license level is not sufficient', async () => { + licensing.getLicense.mockResolvedValue( + licensingMock.createLicense({ license: { type: 'basic' } }) + ); + + await expect( + docManager.install({ force: false, wait: false }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Elastic documentation requires an enterprise license"` + ); + }); + }); + + describe('#update', () => { + beforeEach(() => { + getTaskStatusMock.mockResolvedValue('not_scheduled'); + + docInstallClient.getInstallationStatus.mockResolvedValue({ + kibana: { status: 'uninstalled' }, + } as Awaited>); + }); + + it('calls `scheduleEnsureUpToDateTask`', async () => { + await docManager.update({}); + + expect(scheduleEnsureUpToDateTaskMock).toHaveBeenCalledTimes(1); + expect(scheduleEnsureUpToDateTaskMock).toHaveBeenCalledWith({ + taskManager, + logger, + }); + + expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled(); + }); + + it('calls waitUntilTaskCompleted if wait=true', async () => { + await docManager.update({ wait: true }); + + expect(scheduleEnsureUpToDateTaskMock).toHaveBeenCalledTimes(1); + expect(waitUntilTaskCompletedMock).toHaveBeenCalledTimes(1); + }); + + it('records an audit log when request is provided', async () => { + const request = httpServerMock.createKibanaRequest(); + + const auditLog = auditService.withoutRequest; + auditService.asScoped = jest.fn(() => auditLog); + + await docManager.update({ wait: false, request }); + + expect(auditLog.log).toHaveBeenCalledTimes(1); + expect(auditLog.log).toHaveBeenCalledWith({ + message: expect.any(String), + event: { + action: 'product_documentation_update', + category: ['database'], + type: ['change'], + outcome: 'unknown', + }, + }); + }); + }); + + describe('#uninstall', () => { + beforeEach(() => { + getTaskStatusMock.mockResolvedValue('not_scheduled'); + + docInstallClient.getInstallationStatus.mockResolvedValue({ + kibana: { status: 'uninstalled' }, + } as Awaited>); + }); + + it('calls `scheduleUninstallAllTask`', async () => { + await docManager.uninstall({}); + + expect(scheduleUninstallAllTaskMock).toHaveBeenCalledTimes(1); + expect(scheduleUninstallAllTaskMock).toHaveBeenCalledWith({ + taskManager, + logger, + }); + + expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled(); + }); + + it('calls waitUntilTaskCompleted if wait=true', async () => { + await docManager.uninstall({ wait: true }); + + expect(scheduleUninstallAllTaskMock).toHaveBeenCalledTimes(1); + expect(waitUntilTaskCompletedMock).toHaveBeenCalledTimes(1); + }); + + it('records an audit log when request is provided', async () => { + const request = httpServerMock.createKibanaRequest(); + + const auditLog = auditService.withoutRequest; + auditService.asScoped = jest.fn(() => auditLog); + + await docManager.uninstall({ wait: false, request }); + + expect(auditLog.log).toHaveBeenCalledTimes(1); + expect(auditLog.log).toHaveBeenCalledWith({ + message: expect.any(String), + event: { + action: 'product_documentation_delete', + category: ['database'], + type: ['deletion'], + outcome: 'unknown', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.ts new file mode 100644 index 0000000000000..40dc53e19ceea --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/doc_manager.ts @@ -0,0 +1,204 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { CoreAuditService } from '@kbn/core/server'; +import { type TaskManagerStartContract, TaskStatus } from '@kbn/task-manager-plugin/server'; +import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; +import type { InstallationStatus } from '../../../common/install_status'; +import type { ProductDocInstallClient } from '../doc_install_status'; +import { + INSTALL_ALL_TASK_ID, + scheduleInstallAllTask, + scheduleUninstallAllTask, + scheduleEnsureUpToDateTask, + getTaskStatus, + waitUntilTaskCompleted, +} from '../../tasks'; +import { checkLicense } from './check_license'; +import type { + DocumentationManagerAPI, + DocGetStatusResponse, + DocInstallOptions, + DocUninstallOptions, + DocUpdateOptions, +} from './types'; + +const TEN_MIN_IN_MS = 10 * 60 * 1000; + +/** + * High-level installation service, handling product documentation + * installation as unary operations, abstracting away the fact + * that documentation is composed of multiple entities. + */ +export class DocumentationManager implements DocumentationManagerAPI { + private logger: Logger; + private taskManager: TaskManagerStartContract; + private licensing: LicensingPluginStart; + private docInstallClient: ProductDocInstallClient; + private auditService: CoreAuditService; + + constructor({ + logger, + taskManager, + licensing, + docInstallClient, + auditService, + }: { + logger: Logger; + taskManager: TaskManagerStartContract; + licensing: LicensingPluginStart; + docInstallClient: ProductDocInstallClient; + auditService: CoreAuditService; + }) { + this.logger = logger; + this.taskManager = taskManager; + this.licensing = licensing; + this.docInstallClient = docInstallClient; + this.auditService = auditService; + } + + async install(options: DocInstallOptions = {}): Promise { + const { request, force = false, wait = false } = options; + + const { status } = await this.getStatus(); + if (!force && status === 'installed') { + return; + } + + const license = await this.licensing.getLicense(); + if (!checkLicense(license)) { + throw new Error('Elastic documentation requires an enterprise license'); + } + + const taskId = await scheduleInstallAllTask({ + taskManager: this.taskManager, + logger: this.logger, + }); + + if (request) { + this.auditService.asScoped(request).log({ + message: `User is requesting installation of product documentation for AI Assistants. Task ID=[${taskId}]`, + event: { + action: 'product_documentation_create', + category: ['database'], + type: ['creation'], + outcome: 'unknown', + }, + }); + } + + if (wait) { + await waitUntilTaskCompleted({ + taskManager: this.taskManager, + taskId, + timeout: TEN_MIN_IN_MS, + }); + } + } + + async update(options: DocUpdateOptions = {}): Promise { + const { request, wait = false } = options; + + const taskId = await scheduleEnsureUpToDateTask({ + taskManager: this.taskManager, + logger: this.logger, + }); + + if (request) { + this.auditService.asScoped(request).log({ + message: `User is requesting update of product documentation for AI Assistants. Task ID=[${taskId}]`, + event: { + action: 'product_documentation_update', + category: ['database'], + type: ['change'], + outcome: 'unknown', + }, + }); + } + + if (wait) { + await waitUntilTaskCompleted({ + taskManager: this.taskManager, + taskId, + timeout: TEN_MIN_IN_MS, + }); + } + } + + async uninstall(options: DocUninstallOptions = {}): Promise { + const { request, wait = false } = options; + + const taskId = await scheduleUninstallAllTask({ + taskManager: this.taskManager, + logger: this.logger, + }); + + if (request) { + this.auditService.asScoped(request).log({ + message: `User is requesting deletion of product documentation for AI Assistants. Task ID=[${taskId}]`, + event: { + action: 'product_documentation_delete', + category: ['database'], + type: ['deletion'], + outcome: 'unknown', + }, + }); + } + + if (wait) { + await waitUntilTaskCompleted({ + taskManager: this.taskManager, + taskId, + timeout: TEN_MIN_IN_MS, + }); + } + } + + async getStatus(): Promise { + const taskStatus = await getTaskStatus({ + taskManager: this.taskManager, + taskId: INSTALL_ALL_TASK_ID, + }); + if (taskStatus !== 'not_scheduled') { + const status = convertTaskStatus(taskStatus); + if (status !== 'unknown') { + return { status }; + } + } + + const installStatus = await this.docInstallClient.getInstallationStatus(); + const overallStatus = getOverallStatus(Object.values(installStatus).map((v) => v.status)); + return { status: overallStatus }; + } +} + +const convertTaskStatus = (taskStatus: TaskStatus): InstallationStatus | 'unknown' => { + switch (taskStatus) { + case TaskStatus.Idle: + case TaskStatus.Claiming: + case TaskStatus.Running: + return 'installing'; + case TaskStatus.Failed: + return 'error'; + case TaskStatus.Unrecognized: + case TaskStatus.DeadLetter: + case TaskStatus.ShouldDelete: + default: + return 'unknown'; + } +}; + +const getOverallStatus = (statuses: InstallationStatus[]): InstallationStatus => { + const statusOrder: InstallationStatus[] = ['error', 'installing', 'uninstalled', 'installed']; + for (const status of statusOrder) { + if (statuses.includes(status)) { + return status; + } + } + return 'installed'; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/index.ts new file mode 100644 index 0000000000000..588b5e2f5cc65 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DocumentationManager } from './doc_manager'; +export type { + DocumentationManagerAPI, + DocUninstallOptions, + DocInstallOptions, + DocUpdateOptions, + DocGetStatusResponse, +} from './types'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/types.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/types.ts new file mode 100644 index 0000000000000..5a954a5ffb0fd --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/doc_manager/types.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 type { KibanaRequest } from '@kbn/core/server'; +import type { InstallationStatus } from '../../../common/install_status'; + +/** + * APIs to manage the product documentation. + */ +export interface DocumentationManagerAPI { + /** + * Install the product documentation. + * By default, will only try to install if not already present. + * Can use the `force` option to forcefully reinstall. + */ + install(options?: DocInstallOptions): Promise; + /** + * Update the product documentation to the latest version. + * No-op if the product documentation is not currently installed. + */ + update(options?: DocUpdateOptions): Promise; + /** + * Uninstall the product documentation. + * No-op if the product documentation is not currently installed. + */ + uninstall(options?: DocUninstallOptions): Promise; + /** + * Returns the overall installation status of the documentation. + */ + getStatus(): Promise; +} + +/** + * Return type for {@link DocumentationManagerAPI.getStatus} + */ +export interface DocGetStatusResponse { + status: InstallationStatus; +} + +/** + * Options for {@link DocumentationManagerAPI.install} + */ +export interface DocInstallOptions { + /** + * When the operation was requested by a user, the request that initiated it. + * + * If not provided, the call will be considered as being done on behalf of system. + */ + request?: KibanaRequest; + /** + * If true, will reinstall the documentation even if already present. + * Defaults to `false` + */ + force?: boolean; + /** + * If true, the returned promise will wait until the update task has completed before resolving. + * Defaults to `false` + */ + wait?: boolean; +} + +/** + * Options for {@link DocumentationManagerAPI.uninstall} + */ +export interface DocUninstallOptions { + /** + * When the operation was requested by a user, the request that initiated it. + * + * If not provided, the call will be considered as being done on behalf of system. + */ + request?: KibanaRequest; + /** + * If true, the returned promise will wait until the update task has completed before resolving. + * Defaults to `false` + */ + wait?: boolean; +} + +/** + * Options for {@link DocumentationManagerAPI.update} + */ +export interface DocUpdateOptions { + /** + * When the operation was requested by a user, the request that initiated it. + * + * If not provided, the call will be considered as being done on behalf of system. + */ + request?: KibanaRequest; + /** + * If true, the returned promise will wait until the update task has completed before resolving. + * Defaults to `false` + */ + wait?: boolean; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.test.ts new file mode 100644 index 0000000000000..e5dabaaa9b7f7 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.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 { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { InferenceEndpointManager } from './endpoint_manager'; + +jest.mock('./utils'); +import { installElser, getModelInstallStatus, waitUntilModelDeployed } from './utils'; +const installElserMock = installElser as jest.MockedFn; +const getModelInstallStatusMock = getModelInstallStatus as jest.MockedFn< + typeof getModelInstallStatus +>; +const waitUntilModelDeployedMock = waitUntilModelDeployed as jest.MockedFn< + typeof waitUntilModelDeployed +>; + +describe('InferenceEndpointManager', () => { + let logger: MockedLogger; + let esClient: ReturnType; + let endpointManager: InferenceEndpointManager; + + beforeEach(() => { + logger = loggerMock.create(); + esClient = elasticsearchServiceMock.createElasticsearchClient(); + + endpointManager = new InferenceEndpointManager({ esClient, logger }); + }); + + afterEach(() => { + installElserMock.mockReset(); + getModelInstallStatusMock.mockReset(); + waitUntilModelDeployedMock.mockReset(); + }); + + describe('#ensureInternalElserInstalled', () => { + it('installs ELSER if not already installed', async () => { + getModelInstallStatusMock.mockResolvedValue({ installed: true }); + + await endpointManager.ensureInternalElserInstalled(); + + expect(installElserMock).not.toHaveBeenCalled(); + expect(waitUntilModelDeployedMock).toHaveBeenCalledTimes(1); + }); + it('does not install ELSER if already present', async () => { + getModelInstallStatusMock.mockResolvedValue({ installed: false }); + + await endpointManager.ensureInternalElserInstalled(); + + expect(installElserMock).toHaveBeenCalledTimes(1); + expect(waitUntilModelDeployedMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.ts new file mode 100644 index 0000000000000..4f7467501d61d --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/endpoint_manager.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 { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { internalElserInferenceId } from '../../../common/consts'; +import { installElser, getModelInstallStatus, waitUntilModelDeployed } from './utils'; + +export class InferenceEndpointManager { + private readonly log: Logger; + private readonly esClient: ElasticsearchClient; + + constructor({ logger, esClient }: { logger: Logger; esClient: ElasticsearchClient }) { + this.log = logger; + this.esClient = esClient; + } + + async ensureInternalElserInstalled() { + const { installed } = await getModelInstallStatus({ + inferenceId: internalElserInferenceId, + client: this.esClient, + log: this.log, + }); + if (!installed) { + await installElser({ + inferenceId: internalElserInferenceId, + client: this.esClient, + log: this.log, + }); + } + + await waitUntilModelDeployed({ + modelId: internalElserInferenceId, + client: this.esClient, + log: this.log, + }); + } +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/index.ts new file mode 100644 index 0000000000000..e4098ff58fe51 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/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 { InferenceEndpointManager } from './endpoint_manager'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/service.mock.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/service.mock.ts new file mode 100644 index 0000000000000..e9715c4ad2acd --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/service.mock.ts @@ -0,0 +1,20 @@ +/* + * 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 { InferenceEndpointManager } from './endpoint_manager'; + +export type InferenceEndpointManagerMock = jest.Mocked; + +const createMock = (): InferenceEndpointManagerMock => { + return { + ensureInternalElserInstalled: jest.fn(), + } as unknown as InferenceEndpointManagerMock; +}; + +export const inferenceManagerMock = { + create: createMock, +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/get_model_install_status.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/get_model_install_status.ts new file mode 100644 index 0000000000000..be6caa34d0ad1 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/get_model_install_status.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 type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; + +export const getModelInstallStatus = async ({ + inferenceId, + taskType = 'sparse_embedding', + client, +}: { + inferenceId: string; + taskType?: InferenceTaskType; + client: ElasticsearchClient; + log: Logger; +}) => { + const getInferenceRes = await client.inference.get( + { + task_type: taskType, + inference_id: inferenceId, + }, + { ignore: [404] } + ); + + const installed = (getInferenceRes.endpoints ?? []).some( + (endpoint) => endpoint.inference_id === inferenceId + ); + + return { installed }; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/index.ts new file mode 100644 index 0000000000000..089997557f301 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/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 { waitUntilModelDeployed } from './wait_until_model_deployed'; +export { getModelInstallStatus } from './get_model_install_status'; +export { installElser } from './install_elser'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/install_elser.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/install_elser.ts new file mode 100644 index 0000000000000..0e92d765a3d17 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/install_elser.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 { ElasticsearchClient, Logger } from '@kbn/core/server'; + +export const installElser = async ({ + inferenceId, + client, + log, +}: { + inferenceId: string; + client: ElasticsearchClient; + log: Logger; +}) => { + await client.inference.put( + { + task_type: 'sparse_embedding', + inference_id: inferenceId, + inference_config: { + service: 'elasticsearch', + service_settings: { + num_allocations: 1, + num_threads: 1, + model_id: '.elser_model_2', + }, + task_settings: {}, + }, + }, + { requestTimeout: 5 * 60 * 1000 } + ); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/wait_until_model_deployed.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/wait_until_model_deployed.ts new file mode 100644 index 0000000000000..83775ed80f5a0 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/inference_endpoint/utils/wait_until_model_deployed.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; + +export const waitUntilModelDeployed = async ({ + modelId, + client, + log, + maxRetries = 20, + delay = 2000, +}: { + modelId: string; + client: ElasticsearchClient; + log: Logger; + maxRetries?: number; + delay?: number; +}) => { + for (let i = 0; i < maxRetries; i++) { + const statsRes = await client.ml.getTrainedModelsStats({ + model_id: modelId, + }); + const deploymentStats = statsRes.trained_model_stats[0]?.deployment_stats; + if (!deploymentStats || deploymentStats.nodes.length === 0) { + log.debug(`ML model [${modelId}] was not deployed - attempt ${i + 1} of ${maxRetries}`); + await sleep(delay); + continue; + } + return; + } + + throw new Error(`Timeout waiting for ML model ${modelId} to be deployed`); +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/index.ts new file mode 100644 index 0000000000000..a9edb7c38fdaa --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/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 { PackageInstaller } from './package_installer'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.mocks.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.mocks.ts new file mode 100644 index 0000000000000..3b7b7c234800f --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.mocks.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. + */ + +export const validateArtifactArchiveMock = jest.fn(); +export const fetchArtifactVersionsMock = jest.fn(); +export const createIndexMock = jest.fn(); +export const populateIndexMock = jest.fn(); + +jest.doMock('./steps', () => { + const actual = jest.requireActual('./steps'); + return { + ...actual, + validateArtifactArchive: validateArtifactArchiveMock, + fetchArtifactVersions: fetchArtifactVersionsMock, + createIndex: createIndexMock, + populateIndex: populateIndexMock, + }; +}); + +export const downloadToDiskMock = jest.fn(); +export const openZipArchiveMock = jest.fn(); +export const loadMappingFileMock = jest.fn(); + +jest.doMock('./utils', () => { + const actual = jest.requireActual('./utils'); + return { + ...actual, + downloadToDisk: downloadToDiskMock, + openZipArchive: openZipArchiveMock, + loadMappingFile: loadMappingFileMock, + }; +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.ts new file mode 100644 index 0000000000000..e68bd0e9c5058 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.test.ts @@ -0,0 +1,255 @@ +/* + * 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 { + downloadToDiskMock, + createIndexMock, + populateIndexMock, + loadMappingFileMock, + openZipArchiveMock, + validateArtifactArchiveMock, + fetchArtifactVersionsMock, +} from './package_installer.test.mocks'; + +import { + getArtifactName, + getProductDocIndexName, + DocumentationProduct, + ProductName, +} from '@kbn/product-doc-common'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { installClientMock } from '../doc_install_status/service.mock'; +import { inferenceManagerMock } from '../inference_endpoint/service.mock'; +import type { ProductInstallState } from '../../../common/install_status'; +import { PackageInstaller } from './package_installer'; + +const artifactsFolder = '/lost'; +const artifactRepositoryUrl = 'https://repository.com'; +const kibanaVersion = '8.16.3'; + +const callOrder = (fn: { mock: { invocationCallOrder: number[] } }): number => { + return fn.mock.invocationCallOrder[0]; +}; + +describe('PackageInstaller', () => { + let logger: MockedLogger; + let esClient: ReturnType; + let productDocClient: ReturnType; + let endpointManager: ReturnType; + + let packageInstaller: PackageInstaller; + + beforeEach(() => { + logger = loggerMock.create(); + esClient = elasticsearchServiceMock.createElasticsearchClient(); + productDocClient = installClientMock.create(); + endpointManager = inferenceManagerMock.create(); + packageInstaller = new PackageInstaller({ + artifactsFolder, + logger, + esClient, + productDocClient, + endpointManager, + artifactRepositoryUrl, + kibanaVersion, + }); + }); + + afterEach(() => { + downloadToDiskMock.mockReset(); + createIndexMock.mockReset(); + populateIndexMock.mockReset(); + loadMappingFileMock.mockReset(); + openZipArchiveMock.mockReset(); + validateArtifactArchiveMock.mockReset(); + fetchArtifactVersionsMock.mockReset(); + }); + + describe('installPackage', () => { + it('calls the steps with the right parameters', async () => { + const zipArchive = { + close: jest.fn(), + }; + openZipArchiveMock.mockResolvedValue(zipArchive); + + const mappings = Symbol('mappings'); + loadMappingFileMock.mockResolvedValue(mappings); + + await packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' }); + + const artifactName = getArtifactName({ + productName: 'kibana', + productVersion: '8.16', + }); + const indexName = getProductDocIndexName('kibana'); + expect(endpointManager.ensureInternalElserInstalled).toHaveBeenCalledTimes(1); + + expect(downloadToDiskMock).toHaveBeenCalledTimes(1); + expect(downloadToDiskMock).toHaveBeenCalledWith( + `${artifactRepositoryUrl}/${artifactName}`, + `${artifactsFolder}/${artifactName}` + ); + + expect(openZipArchiveMock).toHaveBeenCalledTimes(1); + expect(openZipArchiveMock).toHaveBeenCalledWith(`${artifactsFolder}/${artifactName}`); + + expect(loadMappingFileMock).toHaveBeenCalledTimes(1); + expect(loadMappingFileMock).toHaveBeenCalledWith(zipArchive); + + expect(createIndexMock).toHaveBeenCalledTimes(1); + expect(createIndexMock).toHaveBeenCalledWith({ + indexName, + mappings, + esClient, + log: logger, + }); + + expect(populateIndexMock).toHaveBeenCalledTimes(1); + expect(populateIndexMock).toHaveBeenCalledWith({ + indexName, + archive: zipArchive, + esClient, + log: logger, + }); + + expect(productDocClient.setInstallationSuccessful).toHaveBeenCalledTimes(1); + expect(productDocClient.setInstallationSuccessful).toHaveBeenCalledWith('kibana', indexName); + + expect(zipArchive.close).toHaveBeenCalledTimes(1); + + expect(productDocClient.setInstallationFailed).not.toHaveBeenCalled(); + }); + + it('executes the steps in the right order', async () => { + await packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' }); + + expect(callOrder(endpointManager.ensureInternalElserInstalled)).toBeLessThan( + callOrder(downloadToDiskMock) + ); + expect(callOrder(downloadToDiskMock)).toBeLessThan(callOrder(openZipArchiveMock)); + expect(callOrder(openZipArchiveMock)).toBeLessThan(callOrder(loadMappingFileMock)); + expect(callOrder(loadMappingFileMock)).toBeLessThan(callOrder(createIndexMock)); + expect(callOrder(createIndexMock)).toBeLessThan(callOrder(populateIndexMock)); + expect(callOrder(populateIndexMock)).toBeLessThan( + callOrder(productDocClient.setInstallationSuccessful) + ); + }); + + it('closes the archive and calls setInstallationFailed if the installation fails', async () => { + const zipArchive = { + close: jest.fn(), + }; + openZipArchiveMock.mockResolvedValue(zipArchive); + + populateIndexMock.mockImplementation(async () => { + throw new Error('something bad'); + }); + + await expect( + packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' }) + ).rejects.toThrowError(); + + expect(productDocClient.setInstallationSuccessful).not.toHaveBeenCalled(); + + expect(zipArchive.close).toHaveBeenCalledTimes(1); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error during documentation installation') + ); + + expect(productDocClient.setInstallationFailed).toHaveBeenCalledTimes(1); + expect(productDocClient.setInstallationFailed).toHaveBeenCalledWith( + 'kibana', + 'something bad' + ); + }); + }); + + describe('installALl', () => { + it('installs all the packages to their latest version', async () => { + jest.spyOn(packageInstaller, 'installPackage'); + + fetchArtifactVersionsMock.mockResolvedValue({ + kibana: ['8.15', '8.16'], + elasticsearch: ['8.15'], + }); + + await packageInstaller.installAll({}); + + expect(packageInstaller.installPackage).toHaveBeenCalledTimes(2); + + expect(packageInstaller.installPackage).toHaveBeenCalledWith({ + productName: 'kibana', + productVersion: '8.16', + }); + expect(packageInstaller.installPackage).toHaveBeenCalledWith({ + productName: 'elasticsearch', + productVersion: '8.15', + }); + }); + }); + + describe('ensureUpToDate', () => { + it('updates the installed packages to the latest version', async () => { + fetchArtifactVersionsMock.mockResolvedValue({ + kibana: ['8.15', '8.16'], + security: ['8.15', '8.16'], + elasticsearch: ['8.15'], + }); + + productDocClient.getInstallationStatus.mockResolvedValue({ + kibana: { status: 'installed', version: '8.15' }, + security: { status: 'installed', version: '8.16' }, + elasticsearch: { status: 'uninstalled' }, + } as Record); + + jest.spyOn(packageInstaller, 'installPackage'); + + await packageInstaller.ensureUpToDate({}); + + expect(packageInstaller.installPackage).toHaveBeenCalledTimes(1); + expect(packageInstaller.installPackage).toHaveBeenCalledWith({ + productName: 'kibana', + productVersion: '8.16', + }); + }); + }); + + describe('uninstallPackage', () => { + it('performs the uninstall steps', async () => { + await packageInstaller.uninstallPackage({ productName: 'kibana' }); + + expect(esClient.indices.delete).toHaveBeenCalledTimes(1); + expect(esClient.indices.delete).toHaveBeenCalledWith( + { + index: getProductDocIndexName('kibana'), + }, + expect.objectContaining({ ignore: [404] }) + ); + + expect(productDocClient.setUninstalled).toHaveBeenCalledTimes(1); + expect(productDocClient.setUninstalled).toHaveBeenCalledWith('kibana'); + }); + }); + + describe('uninstallAll', () => { + it('calls uninstall for all packages', async () => { + jest.spyOn(packageInstaller, 'uninstallPackage'); + + await packageInstaller.uninstallAll(); + + expect(packageInstaller.uninstallPackage).toHaveBeenCalledTimes( + Object.keys(DocumentationProduct).length + ); + Object.values(DocumentationProduct).forEach((productName) => { + expect(packageInstaller.uninstallPackage).toHaveBeenCalledWith({ productName }); + }); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.ts new file mode 100644 index 0000000000000..7739219c15dc6 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/package_installer.ts @@ -0,0 +1,218 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import { + getArtifactName, + getProductDocIndexName, + DocumentationProduct, + type ProductName, +} from '@kbn/product-doc-common'; +import type { ProductDocInstallClient } from '../doc_install_status'; +import type { InferenceEndpointManager } from '../inference_endpoint'; +import { downloadToDisk, openZipArchive, loadMappingFile, type ZipArchive } from './utils'; +import { majorMinor, latestVersion } from './utils/semver'; +import { + validateArtifactArchive, + fetchArtifactVersions, + createIndex, + populateIndex, +} from './steps'; + +interface PackageInstallerOpts { + artifactsFolder: string; + logger: Logger; + esClient: ElasticsearchClient; + productDocClient: ProductDocInstallClient; + endpointManager: InferenceEndpointManager; + artifactRepositoryUrl: string; + kibanaVersion: string; +} + +export class PackageInstaller { + private readonly log: Logger; + private readonly artifactsFolder: string; + private readonly esClient: ElasticsearchClient; + private readonly productDocClient: ProductDocInstallClient; + private readonly endpointManager: InferenceEndpointManager; + private readonly artifactRepositoryUrl: string; + private readonly currentVersion: string; + + constructor({ + artifactsFolder, + logger, + esClient, + productDocClient, + endpointManager, + artifactRepositoryUrl, + kibanaVersion, + }: PackageInstallerOpts) { + this.esClient = esClient; + this.productDocClient = productDocClient; + this.artifactsFolder = artifactsFolder; + this.endpointManager = endpointManager; + this.artifactRepositoryUrl = artifactRepositoryUrl; + this.currentVersion = majorMinor(kibanaVersion); + this.log = logger; + } + + /** + * Make sure that the currently installed doc packages are up to date. + * Will not upgrade products that are not already installed + */ + async ensureUpToDate({}: {}) { + const [repositoryVersions, installStatuses] = await Promise.all([ + fetchArtifactVersions({ + artifactRepositoryUrl: this.artifactRepositoryUrl, + }), + this.productDocClient.getInstallationStatus(), + ]); + + const toUpdate: Array<{ + productName: ProductName; + productVersion: string; + }> = []; + Object.entries(installStatuses).forEach(([productName, productState]) => { + if (productState.status === 'uninstalled') { + return; + } + const availableVersions = repositoryVersions[productName as ProductName]; + if (!availableVersions || !availableVersions.length) { + return; + } + const selectedVersion = selectVersion(this.currentVersion, availableVersions); + if (productState.version !== selectedVersion) { + toUpdate.push({ + productName: productName as ProductName, + productVersion: selectedVersion, + }); + } + }); + + for (const { productName, productVersion } of toUpdate) { + await this.installPackage({ + productName, + productVersion, + }); + } + } + + async installAll({}: {}) { + const repositoryVersions = await fetchArtifactVersions({ + artifactRepositoryUrl: this.artifactRepositoryUrl, + }); + const allProducts = Object.values(DocumentationProduct) as ProductName[]; + for (const productName of allProducts) { + const availableVersions = repositoryVersions[productName]; + if (!availableVersions || !availableVersions.length) { + this.log.warn(`No version found for product [${productName}]`); + continue; + } + const selectedVersion = selectVersion(this.currentVersion, availableVersions); + + await this.installPackage({ + productName, + productVersion: selectedVersion, + }); + } + } + + async installPackage({ + productName, + productVersion, + }: { + productName: ProductName; + productVersion: string; + }) { + this.log.info( + `Starting installing documentation for product [${productName}] and version [${productVersion}]` + ); + + productVersion = majorMinor(productVersion); + + await this.uninstallPackage({ productName }); + + let zipArchive: ZipArchive | undefined; + try { + await this.productDocClient.setInstallationStarted({ + productName, + productVersion, + }); + + await this.endpointManager.ensureInternalElserInstalled(); + + const artifactFileName = getArtifactName({ productName, productVersion }); + const artifactUrl = `${this.artifactRepositoryUrl}/${artifactFileName}`; + const artifactPath = `${this.artifactsFolder}/${artifactFileName}`; + + this.log.debug(`Downloading from [${artifactUrl}] to [${artifactPath}]`); + await downloadToDisk(artifactUrl, artifactPath); + + zipArchive = await openZipArchive(artifactPath); + + validateArtifactArchive(zipArchive); + + const mappings = await loadMappingFile(zipArchive); + + const indexName = getProductDocIndexName(productName); + + await createIndex({ + indexName, + mappings, + esClient: this.esClient, + log: this.log, + }); + + await populateIndex({ + indexName, + archive: zipArchive, + esClient: this.esClient, + log: this.log, + }); + await this.productDocClient.setInstallationSuccessful(productName, indexName); + + this.log.info( + `Documentation installation successful for product [${productName}] and version [${productVersion}]` + ); + } catch (e) { + this.log.error( + `Error during documentation installation of product [${productName}]/[${productVersion}] : ${e.message}` + ); + + await this.productDocClient.setInstallationFailed(productName, e.message); + throw e; + } finally { + zipArchive?.close(); + } + } + + async uninstallPackage({ productName }: { productName: ProductName }) { + const indexName = getProductDocIndexName(productName); + await this.esClient.indices.delete( + { + index: indexName, + }, + { ignore: [404] } + ); + + await this.productDocClient.setUninstalled(productName); + } + + async uninstallAll() { + const allProducts = Object.values(DocumentationProduct); + for (const productName of allProducts) { + await this.uninstallPackage({ productName }); + } + } +} + +const selectVersion = (currentVersion: string, availableVersions: string[]): string => { + return availableVersions.includes(currentVersion) + ? currentVersion + : latestVersion(availableVersions); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.test.ts new file mode 100644 index 0000000000000..fca8b5283c300 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.test.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 type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { createIndex } from './create_index'; +import { internalElserInferenceId } from '../../../../common/consts'; + +describe('createIndex', () => { + let log: MockedLogger; + let esClient: ElasticsearchClient; + + beforeEach(() => { + log = loggerMock.create(); + esClient = elasticsearchServiceMock.createElasticsearchClient(); + }); + + it('calls esClient.indices.create with the right parameters', async () => { + const mappings: MappingTypeMapping = { + properties: {}, + }; + const indexName = '.some-index'; + + await createIndex({ + indexName, + mappings, + log, + esClient, + }); + + expect(esClient.indices.create).toHaveBeenCalledTimes(1); + expect(esClient.indices.create).toHaveBeenCalledWith({ + index: indexName, + mappings, + settings: { + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + }); + }); + + it('rewrites the inference_id attribute of semantic_text fields in the mapping', async () => { + const mappings: MappingTypeMapping = { + properties: { + semantic: { + type: 'semantic_text', + inference_id: '.elser', + }, + bool: { + type: 'boolean', + }, + }, + }; + + await createIndex({ + indexName: '.some-index', + mappings, + log, + esClient, + }); + + expect(esClient.indices.create).toHaveBeenCalledWith( + expect.objectContaining({ + mappings: { + properties: { + semantic: { + type: 'semantic_text', + inference_id: internalElserInferenceId, + }, + bool: { + type: 'boolean', + }, + }, + }, + }) + ); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.ts new file mode 100644 index 0000000000000..decd62e556ba5 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/create_index.ts @@ -0,0 +1,50 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import type { MappingTypeMapping, MappingProperty } from '@elastic/elasticsearch/lib/api/types'; +import { internalElserInferenceId } from '../../../../common/consts'; + +export const createIndex = async ({ + esClient, + indexName, + mappings, + log, +}: { + esClient: ElasticsearchClient; + indexName: string; + mappings: MappingTypeMapping; + log: Logger; +}) => { + log.debug(`Creating index ${indexName}`); + + overrideInferenceId(mappings, internalElserInferenceId); + + await esClient.indices.create({ + index: indexName, + mappings, + settings: { + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + }); +}; + +const overrideInferenceId = (mappings: MappingTypeMapping, inferenceId: string) => { + const recursiveOverride = (current: MappingTypeMapping | MappingProperty) => { + if ('type' in current && current.type === 'semantic_text') { + current.inference_id = inferenceId; + } + if ('properties' in current && current.properties) { + for (const prop of Object.values(current.properties)) { + recursiveOverride(prop); + } + } + }; + recursiveOverride(mappings); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.test.ts new file mode 100644 index 0000000000000..805008ccab698 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.test.ts @@ -0,0 +1,129 @@ +/* + * 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 fetch, { Response } from 'node-fetch'; +import { fetchArtifactVersions } from './fetch_artifact_versions'; +import { getArtifactName, DocumentationProduct, ProductName } from '@kbn/product-doc-common'; + +jest.mock('node-fetch'); +const fetchMock = fetch as jest.MockedFn; + +const createResponse = ({ + artifactNames, + truncated = false, +}: { + artifactNames: string[]; + truncated?: boolean; +}) => { + return ` + + kibana-ai-assistant-kb-artifacts + + + ${truncated} + ${artifactNames.map( + (artifactName) => ` + + ${artifactName} + 1728486063097626 + 1 + 2024-10-09T15:01:03.137Z + "e0584955969eccf2a16b8829f768cb1f" + 36781438 + ` + )} + + `; +}; + +const artifactRepositoryUrl = 'https://lost.com'; + +const expectVersions = ( + versions: Partial> +): Record => { + const response = {} as Record; + Object.values(DocumentationProduct).forEach((productName) => { + response[productName] = []; + }); + return { + ...response, + ...versions, + }; +}; + +describe('fetchArtifactVersions', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + const mockResponse = (responseText: string) => { + const response = { + text: () => Promise.resolve(responseText), + }; + fetchMock.mockResolvedValue(response as Response); + }; + + it('calls fetch with the right parameters', async () => { + mockResponse(createResponse({ artifactNames: [] })); + + await fetchArtifactVersions({ artifactRepositoryUrl }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${artifactRepositoryUrl}?max-keys=1000`); + }); + + it('returns the list of versions from the repository', async () => { + const artifactNames = [ + getArtifactName({ productName: 'kibana', productVersion: '8.16' }), + getArtifactName({ productName: 'elasticsearch', productVersion: '8.16' }), + ]; + mockResponse(createResponse({ artifactNames })); + + const versions = await fetchArtifactVersions({ artifactRepositoryUrl }); + + expect(versions).toEqual( + expectVersions({ + kibana: ['8.16'], + elasticsearch: ['8.16'], + }) + ); + }); + + it('retrieve all versions for each product', async () => { + const artifactNames = [ + getArtifactName({ productName: 'kibana', productVersion: '8.15' }), + getArtifactName({ productName: 'kibana', productVersion: '8.16' }), + getArtifactName({ productName: 'kibana', productVersion: '8.17' }), + getArtifactName({ productName: 'elasticsearch', productVersion: '8.16' }), + getArtifactName({ productName: 'elasticsearch', productVersion: '9.0' }), + ]; + mockResponse(createResponse({ artifactNames })); + + const versions = await fetchArtifactVersions({ artifactRepositoryUrl }); + + expect(versions).toEqual( + expectVersions({ + kibana: ['8.15', '8.16', '8.17'], + elasticsearch: ['8.16', '9.0'], + }) + ); + }); + + it('throws an error if the response is truncated', async () => { + mockResponse(createResponse({ artifactNames: [], truncated: true })); + + await expect(fetchArtifactVersions({ artifactRepositoryUrl })).rejects.toThrowError( + /bucket content is truncated/ + ); + }); + + it('throws an error if the response is not valid xml', async () => { + mockResponse('some plain text'); + + await expect(fetchArtifactVersions({ artifactRepositoryUrl })).rejects.toThrowError(); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.ts new file mode 100644 index 0000000000000..69c6db2d5d8ae --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fetch from 'node-fetch'; +import { parseString } from 'xml2js'; +import { type ProductName, DocumentationProduct, parseArtifactName } from '@kbn/product-doc-common'; + +type ArtifactAvailableVersions = Record; + +export const fetchArtifactVersions = async ({ + artifactRepositoryUrl, +}: { + artifactRepositoryUrl: string; +}): Promise => { + const res = await fetch(`${artifactRepositoryUrl}?max-keys=1000`); + const xml = await res.text(); + return new Promise((resolve, reject) => { + parseString(xml, (err, result: ListBucketResponse) => { + if (err) { + reject(err); + } + + // 6 artifacts per minor stack version means we have a few decades before facing this problem + if (result.ListBucketResult.IsTruncated?.includes('true')) { + throw new Error('bucket content is truncated, cannot retrieve all versions'); + } + + const allowedProductNames: ProductName[] = Object.values(DocumentationProduct); + + const record: ArtifactAvailableVersions = {} as ArtifactAvailableVersions; + allowedProductNames.forEach((product) => { + record[product] = []; + }); + + result.ListBucketResult.Contents?.forEach((contentEntry) => { + const artifactName = contentEntry.Key[0]; + const parsed = parseArtifactName(artifactName); + if (parsed) { + const { productName, productVersion } = parsed; + record[productName]!.push(productVersion); + } + }); + + resolve(record); + }); + }); +}; + +interface ListBucketResponse { + ListBucketResult: { + Name?: string[]; + IsTruncated?: string[]; + Contents?: Array<{ Key: string[] }>; + }; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/index.ts new file mode 100644 index 0000000000000..3c84fc9cccf1a --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/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 { createIndex } from './create_index'; +export { populateIndex } from './populate_index'; +export { validateArtifactArchive } from './validate_artifact_archive'; +export { fetchArtifactVersions } from './fetch_artifact_versions'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.test.ts new file mode 100644 index 0000000000000..2f301f9928e9a --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.test.ts @@ -0,0 +1,109 @@ +/* + * 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 { times } from 'lodash'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { internalElserInferenceId } from '../../../../common/consts'; +import type { ZipArchive } from '../utils/zip_archive'; +import { populateIndex } from './populate_index'; + +const createMockArchive = (entries: Record): ZipArchive => { + return { + hasEntry: (entryPath) => Object.keys(entries).includes(entryPath), + getEntryPaths: () => Object.keys(entries), + getEntryContent: async (entryPath) => Buffer.from(entries[entryPath]), + close: () => undefined, + }; +}; + +const createContentFile = (count: number, offset: number = 0): string => { + return times(count) + .map((i) => JSON.stringify({ idx: offset + i })) + .join('\n'); +}; + +describe('populateIndex', () => { + let log: MockedLogger; + let esClient: ReturnType; + + beforeEach(() => { + log = loggerMock.create(); + esClient = elasticsearchServiceMock.createElasticsearchClient(); + }); + + it('calls `esClient.bulk` once per content file', async () => { + const archive = createMockArchive({ + 'content/content-0.ndjson': createContentFile(2), + 'content/content-1.ndjson': createContentFile(2), + }); + + await populateIndex({ + indexName: '.foo', + archive, + log, + esClient, + }); + + expect(esClient.bulk).toHaveBeenCalledTimes(2); + }); + + it('calls `esClient.bulk` with the right payload', async () => { + const archive = createMockArchive({ + 'content/content-0.ndjson': createContentFile(2), + }); + + await populateIndex({ + indexName: '.foo', + archive, + log, + esClient, + }); + + expect(esClient.bulk).toHaveBeenCalledTimes(1); + expect(esClient.bulk).toHaveBeenCalledWith({ + refresh: false, + operations: [ + { index: { _index: '.foo' } }, + { idx: 0 }, + { index: { _index: '.foo' } }, + { idx: 1 }, + ], + }); + }); + + it('rewrites the inference_id of semantic fields', async () => { + const archive = createMockArchive({ + 'content/content-0.ndjson': JSON.stringify({ + semantic: { text: 'foo', inference: { inference_id: '.some-inference' } }, + }), + }); + + await populateIndex({ + indexName: '.foo', + archive, + log, + esClient, + }); + + expect(esClient.bulk).toHaveBeenCalledTimes(1); + expect(esClient.bulk).toHaveBeenCalledWith({ + refresh: false, + operations: [ + { index: { _index: '.foo' } }, + { + semantic: { + inference: { + inference_id: internalElserInferenceId, + }, + text: 'foo', + }, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.ts new file mode 100644 index 0000000000000..017757ca90b99 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/populate_index.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 type { BulkRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { Logger } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import { isArtifactContentFilePath } from '@kbn/product-doc-common'; +import { internalElserInferenceId } from '../../../../common/consts'; +import type { ZipArchive } from '../utils/zip_archive'; + +export const populateIndex = async ({ + esClient, + indexName, + archive, + log, +}: { + esClient: ElasticsearchClient; + indexName: string; + archive: ZipArchive; + log: Logger; +}) => { + log.debug(`Starting populating index ${indexName}`); + + const contentEntries = archive.getEntryPaths().filter(isArtifactContentFilePath); + + for (let i = 0; i < contentEntries.length; i++) { + const entryPath = contentEntries[i]; + log.debug(`Indexing content for entry ${entryPath}`); + const contentBuffer = await archive.getEntryContent(entryPath); + await indexContentFile({ indexName, esClient, contentBuffer }); + } + + log.debug(`Done populating index ${indexName}`); +}; + +const indexContentFile = async ({ + indexName, + contentBuffer, + esClient, +}: { + indexName: string; + contentBuffer: Buffer; + esClient: ElasticsearchClient; +}) => { + const fileContent = contentBuffer.toString('utf-8'); + const lines = fileContent.split('\n'); + + const documents = lines + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + return JSON.parse(line); + }) + .map((doc) => rewriteInferenceId(doc, internalElserInferenceId)); + + const operations = documents.reduce((ops, document) => { + ops!.push(...[{ index: { _index: indexName } }, document]); + return ops; + }, [] as BulkRequest['operations']); + + const response = await esClient.bulk({ + refresh: false, + operations, + }); + + if (response.errors) { + const error = response.items.find((item) => item.index?.error)?.index?.error ?? 'unknown error'; + throw new Error(`Error indexing documents: ${JSON.stringify(error)}`); + } +}; + +const rewriteInferenceId = (document: Record, inferenceId: string) => { + // we don't need to handle nested fields, we don't have any and won't. + Object.values(document).forEach((field) => { + if (field.inference) { + field.inference.inference_id = inferenceId; + } + }); + return document; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.test.ts new file mode 100644 index 0000000000000..607277aaf3466 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.test.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 type { ZipArchive } from '../utils/zip_archive'; +import { validateArtifactArchive } from './validate_artifact_archive'; + +const createMockArchive = (entryPaths: string[]): ZipArchive => { + return { + hasEntry: (entryPath) => entryPaths.includes(entryPath), + getEntryPaths: () => entryPaths, + getEntryContent: () => { + throw new Error('non implemented'); + }, + close: () => undefined, + }; +}; + +describe('validateArtifactArchive', () => { + it('validates that the archive contains all the mandatory files', () => { + const archive = createMockArchive([ + 'manifest.json', + 'mappings.json', + 'content/content-1.ndjson', + ]); + + const validation = validateArtifactArchive(archive); + + expect(validation).toEqual({ valid: true }); + }); + + it('does not validate if the archive does not contain a manifest', () => { + const archive = createMockArchive(['something.txt']); + + const validation = validateArtifactArchive(archive); + + expect(validation).toMatchInlineSnapshot(` + Object { + "error": "Manifest file not found", + "valid": false, + } + `); + }); + + it('does not validate if the archive does not contain mappings', () => { + const archive = createMockArchive(['manifest.json']); + + const validation = validateArtifactArchive(archive); + + expect(validation).toMatchInlineSnapshot(` + Object { + "error": "Mapping file not found", + "valid": false, + } + `); + }); + + it('does not validate if the archive does not contain content files', () => { + const archive = createMockArchive(['manifest.json', 'mappings.json']); + + const validation = validateArtifactArchive(archive); + + expect(validation).toMatchInlineSnapshot(` + Object { + "error": "No content files were found", + "valid": false, + } + `); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.ts new file mode 100644 index 0000000000000..471d7c080c481 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/steps/validate_artifact_archive.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 { isArtifactContentFilePath } from '@kbn/product-doc-common'; +import type { ZipArchive } from '../utils/zip_archive'; + +type ValidationResult = { valid: true } | { valid: false; error: string }; + +export const validateArtifactArchive = (archive: ZipArchive): ValidationResult => { + if (!archive.hasEntry('manifest.json')) { + return { valid: false, error: 'Manifest file not found' }; + } + if (!archive.hasEntry('mappings.json')) { + return { valid: false, error: 'Mapping file not found' }; + } + if (!archive.getEntryPaths().some(isArtifactContentFilePath)) { + return { valid: false, error: 'No content files were found' }; + } + return { valid: true }; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.test.ts new file mode 100644 index 0000000000000..9d42be652d74d --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; +import type { ArtifactManifest } from '@kbn/product-doc-common'; +import type { ZipArchive } from './zip_archive'; +import { loadManifestFile, loadMappingFile } from './archive_accessors'; + +const createMockArchive = (entries: Record): ZipArchive => { + return { + hasEntry: (entryPath) => Object.keys(entries).includes(entryPath), + getEntryPaths: () => Object.keys(entries), + getEntryContent: async (entryPath) => Buffer.from(entries[entryPath]), + close: () => undefined, + }; +}; + +describe('loadManifestFile', () => { + it('parses the manifest from the archive', async () => { + const manifest: ArtifactManifest = { + formatVersion: '1.0.0', + productName: 'kibana', + productVersion: '8.16', + }; + const archive = createMockArchive({ 'manifest.json': JSON.stringify(manifest) }); + + const parsedManifest = await loadManifestFile(archive); + + expect(parsedManifest).toEqual(manifest); + }); + + it('throws if the archive does not contain the manifest', async () => { + const archive = createMockArchive({}); + + await expect(loadManifestFile(archive)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not load archive file: \\"manifest.json\\" not found in archive"` + ); + }); + + it('throws if the manifest cannot be parsed', async () => { + const archive = createMockArchive({ 'manifest.json': '{}}}{' }); + + await expect(loadManifestFile(archive)).rejects.toThrowError(); + }); +}); + +describe('loadMappingFile', () => { + it('parses the manifest from the archive', async () => { + const mappings: MappingTypeMapping = { + properties: { + foo: { type: 'text' }, + }, + }; + const archive = createMockArchive({ 'mappings.json': JSON.stringify(mappings) }); + + const parsedMappings = await loadMappingFile(archive); + + expect(parsedMappings).toEqual(mappings); + }); + + it('throws if the archive does not contain the manifest', async () => { + const archive = createMockArchive({}); + + await expect(loadMappingFile(archive)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not load archive file: \\"mappings.json\\" not found in archive"` + ); + }); + + it('throws if the manifest cannot be parsed', async () => { + const archive = createMockArchive({ 'mappings.json': '{}}}{' }); + + await expect(loadMappingFile(archive)).rejects.toThrowError(); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.ts new file mode 100644 index 0000000000000..a4ec4f4418f3c --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/archive_accessors.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 type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; +import type { ArtifactManifest } from '@kbn/product-doc-common'; +import type { ZipArchive } from './zip_archive'; + +const manifestEntryPath = 'manifest.json'; +const mappingsEntryPath = 'mappings.json'; + +export const loadManifestFile = async (archive: ZipArchive): Promise => { + return await parseEntryContent(manifestEntryPath, archive); +}; + +export const loadMappingFile = async (archive: ZipArchive): Promise => { + return await parseEntryContent(mappingsEntryPath, archive); +}; + +const parseEntryContent = async (entryPath: string, archive: ZipArchive): Promise => { + if (!archive.hasEntry(entryPath)) { + throw new Error(`Could not load archive file: "${entryPath}" not found in archive`); + } + try { + const buffer = await archive.getEntryContent(entryPath); + return JSON.parse(buffer.toString('utf-8')); + } catch (e) { + throw new Error(`Could not parse archive file: ${e}`); + } +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/download.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/download.ts new file mode 100644 index 0000000000000..ea5357792ef5f --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/download.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 { createWriteStream } from 'fs'; +import { mkdir } from 'fs/promises'; +import Path from 'path'; +import fetch from 'node-fetch'; + +export const downloadToDisk = async (fileUrl: string, filePath: string) => { + const dirPath = Path.dirname(filePath); + await mkdir(dirPath, { recursive: true }); + const res = await fetch(fileUrl); + const fileStream = createWriteStream(filePath); + await new Promise((resolve, reject) => { + res.body.pipe(fileStream); + res.body.on('error', reject); + fileStream.on('finish', resolve); + }); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/index.ts new file mode 100644 index 0000000000000..a612a8c6e9f46 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/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 { downloadToDisk } from './download'; +export { openZipArchive, type ZipArchive } from './zip_archive'; +export { loadManifestFile, loadMappingFile } from './archive_accessors'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.test.ts new file mode 100644 index 0000000000000..9bc20f2eecdbd --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { latestVersion, majorMinor } from './semver'; + +describe('majorMinor', () => { + it('returns the version in a {major.minor} format', () => { + expect(majorMinor('9.17.5')).toEqual('9.17'); + }); + it('ignores qualifiers', () => { + expect(majorMinor('10.42.9000-snap')).toEqual('10.42'); + }); + it('accepts {major.minor} format as input', () => { + expect(majorMinor('8.16')).toEqual('8.16'); + }); +}); + +describe('latestVersion', () => { + it('returns the highest version from the list', () => { + expect(latestVersion(['7.16.3', '8.1.4', '6.14.2'])).toEqual('8.1.4'); + }); + it('accepts versions in a {major.minor} format', () => { + expect(latestVersion(['9.16', '9.3'])).toEqual('9.16'); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.ts new file mode 100644 index 0000000000000..b4e38215af90e --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/semver.ts @@ -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 Semver from 'semver'; + +export const latestVersion = (versions: string[]): string => { + let latest: string = versions[0]; + for (let i = 1; i < versions.length; i++) { + const current = versions[i]; + if (Semver.gt(Semver.coerce(current)!, Semver.coerce(latest)!)) { + latest = current; + } + } + return latest; +}; + +export const majorMinor = (version: string): string => { + const parsed = Semver.coerce(version); + if (!parsed) { + throw new Error(`Not a valid semver version: [${version}]`); + } + return `${parsed.major}.${parsed.minor}`; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/test_data/test_archive_1.zip b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/test_data/test_archive_1.zip new file mode 100644 index 0000000000000000000000000000000000000000..fce195d2c4db26c8590ec97f45dc9dfc3fcf8346 GIT binary patch literal 800 zcmWIWW@h1H00EW;|H!p|Div%%HVAVu$S{6KKJgobc3FlUtVrDpZQZmq# zR2(K5B20>^@{a^M6%N>ezROE3E=f(%2YYRIHedQ4AV%{VssZbSK3(Sk8Uez>2m>JI z#3S4UGOQTWO)zx<-i%Cg%(y~b0_;X$*fK0>1Tm50fE5x47>-1khZ*+B=6S))gT@5V zJWx#FF%L5akjv#`n9z0fnhJgZ-fMKX{g6ueX eU>HITgM~aK)!;Ii6_m^vSb=aFP-_V&3K#%85|UK_ literal 0 HcmV?d00001 diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.test.ts new file mode 100644 index 0000000000000..71cd5891c5e5d --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Path from 'path'; +import { openZipArchive, ZipArchive } from './zip_archive'; + +const ZIP_PATH = Path.resolve(__dirname, './test_data/test_archive_1.zip'); + +describe('ZipArchive', () => { + let archive: ZipArchive; + + beforeAll(async () => { + archive = await openZipArchive(ZIP_PATH); + }); + + afterAll(() => { + archive?.close(); + }); + + test('#getEntryPaths returns the path of all entries', () => { + expect(archive.getEntryPaths().sort()).toEqual([ + 'nested/', + 'nested/nested_1.txt', + 'text_1.txt', + 'text_2.txt', + 'text_3.txt', + ]); + }); + + test('#hasEntry returns true if the entry exists, false otherwise', () => { + expect(archive.hasEntry('nested/nested_1.txt')).toBe(true); + expect(archive.hasEntry('not_an_entry')).toBe(false); + }); + + test('#getEntryContent returns the content of the entry', async () => { + const buffer = await archive.getEntryContent('text_1.txt'); + expect(buffer.toString('utf-8')).toEqual('text_1'); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.ts new file mode 100644 index 0000000000000..dbc4ec1b3e41f --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/package_installer/utils/zip_archive.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import yauzl from 'yauzl'; + +export interface ZipArchive { + hasEntry(entryPath: string): boolean; + getEntryPaths(): string[]; + getEntryContent(entryPath: string): Promise; + close(): void; +} + +export const openZipArchive = async (archivePath: string): Promise => { + return new Promise((resolve, reject) => { + const entries: yauzl.Entry[] = []; + yauzl.open(archivePath, { lazyEntries: true, autoClose: false }, (err, zipFile) => { + if (err || !zipFile) { + return reject(err ?? 'No zip file'); + } + + zipFile!.on('entry', (entry) => { + entries.push(entry); + zipFile.readEntry(); + }); + + zipFile.on('end', () => { + const archive = new ZipArchiveImpl(entries, zipFile); + resolve(archive); + }); + + zipFile.on('close', () => {}); + + zipFile.readEntry(); + }); + }); +}; + +class ZipArchiveImpl implements ZipArchive { + private readonly zipFile: yauzl.ZipFile; + private readonly entries: Map; + + constructor(entries: yauzl.Entry[], zipFile: yauzl.ZipFile) { + this.zipFile = zipFile; + this.entries = new Map(entries.map((entry) => [entry.fileName, entry])); + } + + hasEntry(entryPath: string) { + return this.entries.has(entryPath); + } + + getEntryPaths() { + return [...this.entries.keys()]; + } + + getEntryContent(entryPath: string) { + const foundEntry = this.entries.get(entryPath); + if (!foundEntry) { + throw new Error(`Entry ${entryPath} not found in archive`); + } + return getZipEntryContent(this.zipFile, foundEntry); + } + + close() { + this.zipFile.close(); + } +} + +const getZipEntryContent = async (zipFile: yauzl.ZipFile, entry: yauzl.Entry): Promise => { + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (err, readStream) => { + if (err) { + return reject(err); + } else { + const chunks: Buffer[] = []; + readStream!.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + readStream!.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + readStream!.on('error', () => { + reject(); + }); + } + }); + }); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/index.ts new file mode 100644 index 0000000000000..3e5ac95ae4edf --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SearchService } from './search_service'; +export type { DocSearchOptions, DocSearchResult, DocSearchResponse, SearchApi } from './types'; diff --git a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/perform_semantic_search.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/perform_search.ts similarity index 78% rename from x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/perform_semantic_search.ts rename to x-pack/plugins/ai_infra/product_doc_base/server/services/search/perform_search.ts index 373a6b8755429..03c3b72f86f92 100644 --- a/x-pack/packages/ai-infra/product-doc-artifact-builder/src/tasks/perform_semantic_search.ts +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/perform_search.ts @@ -5,29 +5,27 @@ * 2.0. */ -import type { Client } from '@elastic/elasticsearch'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import type { ProductDocumentationAttributes } from '@kbn/product-doc-common'; // https://search-labs.elastic.co/search-labs/blog/elser-rag-search-for-relevance -export const performSemanticSearch = async ({ +export const performSearch = async ({ searchQuery, + size, index, client, }: { searchQuery: string; - index: string; - client: Client; + size: number; + index: string | string[]; + client: ElasticsearchClient; }) => { - const results = await client.search({ + const results = await client.search({ index, - size: 3, + size, query: { bool: { - filter: { - bool: { - must: [{ term: { version: '8.15' } }], - }, - }, should: [ { multi_match: { @@ -37,7 +35,7 @@ export const performSemanticSearch = async ({ fields: [ 'content_title', 'content_body.text', - 'ai_subtitle.text', + 'ai_subtitle', 'ai_summary.text', 'ai_questions_answered.text', 'ai_tags', @@ -65,12 +63,6 @@ export const performSemanticSearch = async ({ query: searchQuery, }, }, - { - semantic: { - field: 'ai_subtitle', - query: searchQuery, - }, - }, { semantic: { field: 'ai_summary', diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.test.ts new file mode 100644 index 0000000000000..c8053ca981e71 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { SearchService } from './search_service'; +import { getIndicesForProductNames } from './utils'; + +import { performSearch } from './perform_search'; +jest.mock('./perform_search'); +const performSearchMock = performSearch as jest.MockedFn; + +describe('SearchService', () => { + let logger: MockedLogger; + let esClient: ReturnType; + let service: SearchService; + + beforeEach(() => { + logger = loggerMock.create(); + esClient = elasticsearchServiceMock.createElasticsearchClient(); + service = new SearchService({ logger, esClient }); + + performSearchMock.mockResolvedValue([]); + }); + + afterEach(() => { + performSearchMock.mockReset(); + }); + + describe('#search', () => { + it('calls `performSearch` with the right parameters', async () => { + await service.search({ + query: 'What is Kibana?', + products: ['kibana'], + max: 42, + }); + + expect(performSearchMock).toHaveBeenCalledTimes(1); + expect(performSearchMock).toHaveBeenCalledWith({ + searchQuery: 'What is Kibana?', + size: 42, + index: getIndicesForProductNames(['kibana']), + client: esClient, + }); + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.ts new file mode 100644 index 0000000000000..a0b1e4fd4a836 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/search_service.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 { Logger } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import { getIndicesForProductNames, mapResult } from './utils'; +import { performSearch } from './perform_search'; +import type { DocSearchOptions, DocSearchResponse } from './types'; + +export class SearchService { + private readonly log: Logger; + private readonly esClient: ElasticsearchClient; + + constructor({ logger, esClient }: { logger: Logger; esClient: ElasticsearchClient }) { + this.log = logger; + this.esClient = esClient; + } + + async search(options: DocSearchOptions): Promise { + const { query, max = 3, products } = options; + this.log.debug(`performing search - query=[${query}]`); + const results = await performSearch({ + searchQuery: query, + size: max, + index: getIndicesForProductNames(products), + client: this.esClient, + }); + + return { + results: results.map(mapResult), + }; + } +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/types.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/types.ts new file mode 100644 index 0000000000000..fb474bbf4deab --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/types.ts @@ -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 type { ProductName } from '@kbn/product-doc-common'; + +export interface DocSearchOptions { + query: string; + max?: number; + products?: ProductName[]; +} + +export interface DocSearchResult { + title: string; + content: string; + url: string; + productName: ProductName; +} + +export interface DocSearchResponse { + results: DocSearchResult[]; +} + +export type SearchApi = (options: DocSearchOptions) => Promise; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.test.ts new file mode 100644 index 0000000000000..0293d086d4f13 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.test.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 { productDocIndexPattern, getProductDocIndexName } from '@kbn/product-doc-common'; +import { getIndicesForProductNames } from './get_indices_for_product_names'; + +describe('getIndicesForProductNames', () => { + it('returns the index pattern when product names are not specified', () => { + expect(getIndicesForProductNames(undefined)).toEqual(productDocIndexPattern); + expect(getIndicesForProductNames([])).toEqual(productDocIndexPattern); + }); + it('returns individual index names when product names are specified', () => { + expect(getIndicesForProductNames(['kibana', 'elasticsearch'])).toEqual([ + getProductDocIndexName('kibana'), + getProductDocIndexName('elasticsearch'), + ]); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.ts new file mode 100644 index 0000000000000..e97ed9cea3611 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/get_indices_for_product_names.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 { + productDocIndexPattern, + getProductDocIndexName, + type ProductName, +} from '@kbn/product-doc-common'; + +export const getIndicesForProductNames = ( + productNames: ProductName[] | undefined +): string | string[] => { + if (!productNames || !productNames.length) { + return productDocIndexPattern; + } + return productNames.map(getProductDocIndexName); +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/index.ts new file mode 100644 index 0000000000000..1a6a2eaa24a99 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getIndicesForProductNames } from './get_indices_for_product_names'; +export { mapResult } from './map_result'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.test.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.test.ts new file mode 100644 index 0000000000000..56e8ce4875cc5 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.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 type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import type { ProductDocumentationAttributes } from '@kbn/product-doc-common'; +import { mapResult } from './map_result'; + +const createHit = ( + attrs: ProductDocumentationAttributes +): SearchHit => { + return { + _index: '.foo', + _source: attrs, + }; +}; + +describe('mapResult', () => { + it('returns the expected shape', () => { + const input = createHit({ + content_title: 'content_title', + content_body: { text: 'content_body' }, + product_name: 'kibana', + root_type: 'documentation', + slug: 'foo.html', + url: 'http://lost.com/foo.html', + version: '8.16', + ai_subtitle: 'ai_subtitle', + ai_summary: { text: 'ai_summary' }, + ai_questions_answered: { text: ['question A'] }, + ai_tags: ['foo', 'bar', 'test'], + }); + + const output = mapResult(input); + + expect(output).toEqual({ + content: 'content_body', + productName: 'kibana', + title: 'content_title', + url: 'http://lost.com/foo.html', + }); + }); +}); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.ts b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.ts new file mode 100644 index 0000000000000..f4f66b2111827 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/services/search/utils/map_result.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 type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import type { ProductDocumentationAttributes } from '@kbn/product-doc-common'; +import type { DocSearchResult } from '../types'; + +export const mapResult = (docHit: SearchHit): DocSearchResult => { + return { + title: docHit._source!.content_title, + content: docHit._source!.content_body.text, + url: docHit._source!.url, + productName: docHit._source!.product_name, + }; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/tasks/ensure_up_to_date.ts b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/ensure_up_to_date.ts new file mode 100644 index 0000000000000..d971561914ff1 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/ensure_up_to_date.ts @@ -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 type { Logger } from '@kbn/logging'; +import type { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { InternalServices } from '../types'; +import { isTaskCurrentlyRunningError } from './utils'; + +export const ENSURE_DOC_UP_TO_DATE_TASK_TYPE = 'ProductDocBase:EnsureUpToDate'; +export const ENSURE_DOC_UP_TO_DATE_TASK_ID = 'ProductDocBase:EnsureUpToDate'; + +export const registerEnsureUpToDateTaskDefinition = ({ + getServices, + taskManager, +}: { + getServices: () => InternalServices; + taskManager: TaskManagerSetupContract; +}) => { + taskManager.registerTaskDefinitions({ + [ENSURE_DOC_UP_TO_DATE_TASK_TYPE]: { + title: 'Ensure product documentation up to date task', + timeout: '10m', + maxAttempts: 3, + createTaskRunner: (context) => { + return { + async run() { + const { packageInstaller } = getServices(); + return packageInstaller.ensureUpToDate({}); + }, + }; + }, + stateSchemaByVersion: {}, + }, + }); +}; + +export const scheduleEnsureUpToDateTask = async ({ + taskManager, + logger, +}: { + taskManager: TaskManagerStartContract; + logger: Logger; +}) => { + try { + await taskManager.ensureScheduled({ + id: ENSURE_DOC_UP_TO_DATE_TASK_ID, + taskType: ENSURE_DOC_UP_TO_DATE_TASK_TYPE, + params: {}, + state: {}, + scope: ['productDoc'], + }); + + await taskManager.runSoon(ENSURE_DOC_UP_TO_DATE_TASK_ID); + + logger.info(`Task ${ENSURE_DOC_UP_TO_DATE_TASK_ID} scheduled to run soon`); + } catch (e) { + if (!isTaskCurrentlyRunningError(e)) { + throw e; + } + } + + return ENSURE_DOC_UP_TO_DATE_TASK_ID; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/tasks/index.ts b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/index.ts new file mode 100644 index 0000000000000..0b5833055fd8b --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; +import type { InternalServices } from '../types'; +import { registerEnsureUpToDateTaskDefinition } from './ensure_up_to_date'; +import { registerInstallAllTaskDefinition } from './install_all'; +import { registerUninstallAllTaskDefinition } from './uninstall_all'; + +export const registerTaskDefinitions = ({ + getServices, + taskManager, +}: { + getServices: () => InternalServices; + taskManager: TaskManagerSetupContract; +}) => { + registerEnsureUpToDateTaskDefinition({ getServices, taskManager }); + registerInstallAllTaskDefinition({ getServices, taskManager }); + registerUninstallAllTaskDefinition({ getServices, taskManager }); +}; + +export { scheduleEnsureUpToDateTask, ENSURE_DOC_UP_TO_DATE_TASK_ID } from './ensure_up_to_date'; +export { scheduleInstallAllTask, INSTALL_ALL_TASK_ID } from './install_all'; +export { scheduleUninstallAllTask, UNINSTALL_ALL_TASK_ID } from './uninstall_all'; +export { waitUntilTaskCompleted, getTaskStatus } from './utils'; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/tasks/install_all.ts b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/install_all.ts new file mode 100644 index 0000000000000..0d2cc48fb06bb --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/install_all.ts @@ -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 type { Logger } from '@kbn/logging'; +import type { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { InternalServices } from '../types'; +import { isTaskCurrentlyRunningError } from './utils'; + +export const INSTALL_ALL_TASK_TYPE = 'ProductDocBase:InstallAll'; +export const INSTALL_ALL_TASK_ID = 'ProductDocBase:InstallAll'; + +export const registerInstallAllTaskDefinition = ({ + getServices, + taskManager, +}: { + getServices: () => InternalServices; + taskManager: TaskManagerSetupContract; +}) => { + taskManager.registerTaskDefinitions({ + [INSTALL_ALL_TASK_TYPE]: { + title: 'Install all product documentation artifacts', + timeout: '10m', + maxAttempts: 3, + createTaskRunner: (context) => { + return { + async run() { + const { packageInstaller } = getServices(); + return packageInstaller.installAll({}); + }, + }; + }, + stateSchemaByVersion: {}, + }, + }); +}; + +export const scheduleInstallAllTask = async ({ + taskManager, + logger, +}: { + taskManager: TaskManagerStartContract; + logger: Logger; +}) => { + try { + await taskManager.ensureScheduled({ + id: INSTALL_ALL_TASK_ID, + taskType: INSTALL_ALL_TASK_TYPE, + params: {}, + state: {}, + scope: ['productDoc'], + }); + + await taskManager.runSoon(INSTALL_ALL_TASK_ID); + + logger.info(`Task ${INSTALL_ALL_TASK_ID} scheduled to run soon`); + } catch (e) { + if (!isTaskCurrentlyRunningError(e)) { + throw e; + } + } + + return INSTALL_ALL_TASK_ID; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/tasks/uninstall_all.ts b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/uninstall_all.ts new file mode 100644 index 0000000000000..6a88fec205ddd --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/uninstall_all.ts @@ -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 type { Logger } from '@kbn/logging'; +import type { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { InternalServices } from '../types'; +import { isTaskCurrentlyRunningError } from './utils'; + +export const UNINSTALL_ALL_TASK_TYPE = 'ProductDocBase:UninstallAll'; +export const UNINSTALL_ALL_TASK_ID = 'ProductDocBase:UninstallAll'; + +export const registerUninstallAllTaskDefinition = ({ + getServices, + taskManager, +}: { + getServices: () => InternalServices; + taskManager: TaskManagerSetupContract; +}) => { + taskManager.registerTaskDefinitions({ + [UNINSTALL_ALL_TASK_TYPE]: { + title: 'Uninstall all product documentation artifacts', + timeout: '10m', + maxAttempts: 3, + createTaskRunner: (context) => { + return { + async run() { + const { packageInstaller } = getServices(); + return packageInstaller.uninstallAll(); + }, + }; + }, + stateSchemaByVersion: {}, + }, + }); +}; + +export const scheduleUninstallAllTask = async ({ + taskManager, + logger, +}: { + taskManager: TaskManagerStartContract; + logger: Logger; +}) => { + try { + await taskManager.ensureScheduled({ + id: UNINSTALL_ALL_TASK_ID, + taskType: UNINSTALL_ALL_TASK_TYPE, + params: {}, + state: {}, + scope: ['productDoc'], + }); + + await taskManager.runSoon(UNINSTALL_ALL_TASK_ID); + + logger.info(`Task ${UNINSTALL_ALL_TASK_ID} scheduled to run soon`); + } catch (e) { + if (!isTaskCurrentlyRunningError(e)) { + throw e; + } + } + + return UNINSTALL_ALL_TASK_ID; +}; diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/tasks/utils.ts b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/utils.ts new file mode 100644 index 0000000000000..e32ea02a11b0c --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/tasks/utils.ts @@ -0,0 +1,69 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; + +export const getTaskStatus = async ({ + taskManager, + taskId, +}: { + taskManager: TaskManagerStartContract; + taskId: string; +}) => { + try { + const taskInstance = await taskManager.get(taskId); + return taskInstance.status; + } catch (e) { + // not found means the task was completed and the entry removed + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + return 'not_scheduled'; + } + throw e; + } +}; + +export const isTaskCurrentlyRunningError = (err: Error): boolean => { + return err.message?.includes('currently running'); +}; + +export const waitUntilTaskCompleted = async ({ + taskManager, + taskId, + timeout = 120_000, + interval = 5_000, +}: { + taskManager: TaskManagerStartContract; + taskId: string; + timeout?: number; + interval?: number; +}): Promise => { + const start = Date.now(); + const max = start + timeout; + let now = start; + while (now < max) { + try { + const taskInstance = await taskManager.get(taskId); + const { status } = taskInstance; + if (status === 'idle' || status === 'claiming' || status === 'running') { + await sleep(interval); + now = Date.now(); + } else { + return; + } + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + // not found means the task was completed and the entry removed + return; + } + } + } + + throw new Error(`Timeout waiting for task ${taskId} to complete.`); +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/x-pack/plugins/ai_infra/product_doc_base/server/types.ts b/x-pack/plugins/ai_infra/product_doc_base/server/types.ts new file mode 100644 index 0000000000000..f00943b696708 --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/server/types.ts @@ -0,0 +1,44 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; +import type { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { SearchApi } from './services/search'; +import type { ProductDocInstallClient } from './services/doc_install_status'; +import type { PackageInstaller } from './services/package_installer'; +import type { DocumentationManager, DocumentationManagerAPI } from './services/doc_manager'; + +/* eslint-disable @typescript-eslint/no-empty-interface*/ + +export interface ProductDocBaseSetupDependencies { + taskManager: TaskManagerSetupContract; +} + +export interface ProductDocBaseStartDependencies { + licensing: LicensingPluginStart; + taskManager: TaskManagerStartContract; +} + +export interface ProductDocBaseSetupContract {} + +export interface ProductDocBaseStartContract { + search: SearchApi; + management: DocumentationManagerAPI; +} + +export interface InternalServices { + logger: Logger; + installClient: ProductDocInstallClient; + packageInstaller: PackageInstaller; + documentationManager: DocumentationManager; + licensing: LicensingPluginStart; + taskManager: TaskManagerStartContract; +} diff --git a/x-pack/plugins/ai_infra/product_doc_base/tsconfig.json b/x-pack/plugins/ai_infra/product_doc_base/tsconfig.json new file mode 100644 index 0000000000000..9a2d1969556bf --- /dev/null +++ b/x-pack/plugins/ai_infra/product_doc_base/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "../../../../typings/**/*", + "common/**/*", + "public/**/*", + "typings/**/*", + "public/**/*.json", + "server/**/*", + "scripts/**/*", + ".storybook/**/*" + ], + "exclude": ["target/**/*", ".storybook/**/*.js"], + "kbn_references": [ + "@kbn/core", + "@kbn/logging", + "@kbn/config-schema", + "@kbn/product-doc-common", + "@kbn/core-saved-objects-server", + "@kbn/utils", + "@kbn/core-http-browser", + "@kbn/logging-mocks", + "@kbn/licensing-plugin", + "@kbn/task-manager-plugin", + ] +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts index 361d13e6d77f2..f693fa53c06cc 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts @@ -77,7 +77,7 @@ export class ObservabilityAIAssistantPlugin privileges: { all: { app: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'kibana'], - api: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'ai_assistant'], + api: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'ai_assistant', 'manage_llm_product_doc'], catalogue: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID], savedObject: { all: [ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc index efc948503b0c0..957ca0272c087 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc @@ -32,7 +32,8 @@ "alerting", "features", "inference", - "logsDataAccess" + "logsDataAccess", + "llmTasks" ], "optionalPlugins": [ "cloud" diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/documentation.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/documentation.ts new file mode 100644 index 0000000000000..00072e0c79c48 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/documentation.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 { DocumentationProduct } from '@kbn/product-doc-common'; +import { FunctionVisibility } from '@kbn/observability-ai-assistant-plugin/common'; +import type { FunctionRegistrationParameters } from '.'; + +export const RETRIEVE_DOCUMENTATION_NAME = 'retrieve_elastic_doc'; + +export async function registerDocumentationFunction({ + functions, + resources, + pluginsStart: { llmTasks }, +}: FunctionRegistrationParameters) { + const isProductDocAvailable = (await llmTasks.retrieveDocumentationAvailable()) ?? false; + + functions.registerInstruction(({ availableFunctionNames }) => { + return availableFunctionNames.includes(RETRIEVE_DOCUMENTATION_NAME) + ? `When asked questions about the Elastic stack or products, You should use the ${RETRIEVE_DOCUMENTATION_NAME} function before answering, + to retrieve documentation related to the question. Consider that the documentation returned by the function + is always more up to date and accurate than any own internal knowledge you might have.` + : undefined; + }); + + functions.registerFunction( + { + name: RETRIEVE_DOCUMENTATION_NAME, + visibility: isProductDocAvailable + ? FunctionVisibility.AssistantOnly + : FunctionVisibility.Internal, + description: `Use this function to retrieve documentation about Elastic products. + You can retrieve documentation about the Elastic stack, such as Kibana and Elasticsearch, + or for Elastic solutions, such as Elastic Security, Elastic Observability or Elastic Enterprise Search + `, + parameters: { + type: 'object', + properties: { + query: { + description: `The query to use to retrieve documentation + Examples: + - "How to enable TLS for Elasticsearch?" + - "What is Kibana Lens?"`, + type: 'string' as const, + }, + product: { + description: `If specified, will filter the products to retrieve documentation for + Possible options are: + - "kibana": Kibana product + - "elasticsearch": Elasticsearch product + - "observability": Elastic Observability solution + - "security": Elastic Security solution + If not specified, will search against all products + `, + type: 'string' as const, + enum: Object.values(DocumentationProduct), + }, + }, + required: ['query'], + } as const, + }, + async ({ arguments: { query, product }, connectorId, useSimulatedFunctionCalling }) => { + const response = await llmTasks!.retrieveDocumentation({ + searchTerm: query, + products: product ? [product] : undefined, + max: 3, + connectorId, + request: resources.request, + functionCalling: useSimulatedFunctionCalling ? 'simulated' : 'native', + }); + + return { + content: { + documents: response.documents, + }, + }; + } + ); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/index.ts index 7554164a55a69..ba876ad9457bc 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/index.ts @@ -12,6 +12,7 @@ import { registerLensFunction } from './lens'; import { registerVisualizeESQLFunction } from './visualize_esql'; import { ObservabilityAIAssistantAppPluginStartDependencies } from '../types'; import { registerChangesFunction } from './changes'; +import { registerDocumentationFunction } from './documentation'; export type FunctionRegistrationParameters = Omit< Parameters[0], @@ -24,4 +25,5 @@ export const registerFunctions = async (registrationParameters: FunctionRegistra registerVisualizeESQLFunction(registrationParameters); registerAlertsFunction(registrationParameters); registerChangesFunction(registrationParameters); + await registerDocumentationFunction(registrationParameters); }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts index fc39e0b7fb24e..a1196be6a829a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts @@ -37,6 +37,7 @@ import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plu import type { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; import type { InferenceServerStart, InferenceServerSetup } from '@kbn/inference-plugin/server'; import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/server'; +import type { LlmTasksPluginStart } from '@kbn/llm-tasks-plugin/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ObservabilityAIAssistantAppServerStart {} @@ -57,6 +58,7 @@ export interface ObservabilityAIAssistantAppPluginStartDependencies { serverless?: ServerlessPluginStart; inference: InferenceServerStart; logsDataAccess: LogsDataAccessPluginStart; + llmTasks: LlmTasksPluginStart; } export interface ObservabilityAIAssistantAppPluginSetupDependencies { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json index 6608799caaf61..e0a520fb574c7 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json @@ -70,6 +70,8 @@ "@kbn/logs-data-access-plugin", "@kbn/ai-assistant-common", "@kbn/inference-common", + "@kbn/llm-tasks-plugin", + "@kbn/product-doc-common", ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc index cda6fdf0192fa..c228f147dbfc3 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc @@ -21,10 +21,11 @@ "optionalPlugins": [ "home", "serverless", + "productDocBase" ], "requiredBundles": [ "kibanaReact", - "logsDataAccess", + "logsDataAccess" ] } } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts index a680da5ed3f93..3bfe3dff3f9f4 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts @@ -9,6 +9,9 @@ export const REACT_QUERY_KEYS = { GET_GENAI_CONNECTORS: 'get_genai_connectors', GET_KB_ENTRIES: 'get_kb_entries', GET_KB_USER_INSTRUCTIONS: 'get_kb_user_instructions', + GET_PRODUCT_DOC_STATUS: 'get_product_doc_status', + INSTALL_PRODUCT_DOC: 'install_product_doc', + UNINSTALL_PRODUCT_DOC: 'uninstall_product_doc', CREATE_KB_ENTRIES: 'create_kb_entry', IMPORT_KB_ENTRIES: 'import_kb_entry', }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_product_doc_status.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_product_doc_status.ts new file mode 100644 index 0000000000000..ef95d51f78d49 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_product_doc_status.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 { useQuery } from '@tanstack/react-query'; +import { REACT_QUERY_KEYS } from '../constants'; +import { useKibana } from './use_kibana'; + +export function useGetProductDocStatus() { + const { productDocBase } = useKibana().services; + + const { isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery({ + queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS], + queryFn: async () => { + return productDocBase!.installation.getStatus(); + }, + keepPreviousData: false, + refetchOnWindowFocus: false, + }); + + return { + status: data, + refetch, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_install_product_doc.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_install_product_doc.ts new file mode 100644 index 0000000000000..cb32efa7e3908 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_install_product_doc.ts @@ -0,0 +1,57 @@ +/* + * 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 { useMutation, useQueryClient } from '@tanstack/react-query'; +import { i18n } from '@kbn/i18n'; +import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import type { PerformInstallResponse } from '@kbn/product-doc-base-plugin/common/http_api/installation'; +import { REACT_QUERY_KEYS } from '../constants'; +import { useKibana } from './use_kibana'; + +type ServerError = IHttpFetchError; + +export function useInstallProductDoc() { + const { + productDocBase, + notifications: { toasts }, + } = useKibana().services; + const queryClient = useQueryClient(); + + return useMutation( + [REACT_QUERY_KEYS.INSTALL_PRODUCT_DOC], + () => { + return productDocBase!.installation.install(); + }, + { + onSuccess: () => { + toasts.addSuccess( + i18n.translate( + 'xpack.observabilityAiAssistantManagement.kb.installProductDoc.successNotification', + { + defaultMessage: 'The Elastic documentation was successfully installed', + } + ) + ); + + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS], + refetchType: 'all', + }); + }, + onError: (error) => { + toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.translate( + 'xpack.observabilityAiAssistantManagement.kb.installProductDoc.errorNotification', + { + defaultMessage: 'Something went wrong while installing the Elastic documentation', + } + ), + }); + }, + } + ); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_uninstall_product_doc.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_uninstall_product_doc.ts new file mode 100644 index 0000000000000..4aa3b5423faa1 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_uninstall_product_doc.ts @@ -0,0 +1,57 @@ +/* + * 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 { useMutation, useQueryClient } from '@tanstack/react-query'; +import { i18n } from '@kbn/i18n'; +import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import type { UninstallResponse } from '@kbn/product-doc-base-plugin/common/http_api/installation'; +import { REACT_QUERY_KEYS } from '../constants'; +import { useKibana } from './use_kibana'; + +type ServerError = IHttpFetchError; + +export function useUninstallProductDoc() { + const { + productDocBase, + notifications: { toasts }, + } = useKibana().services; + const queryClient = useQueryClient(); + + return useMutation( + [REACT_QUERY_KEYS.UNINSTALL_PRODUCT_DOC], + () => { + return productDocBase!.installation.uninstall(); + }, + { + onSuccess: () => { + toasts.addSuccess( + i18n.translate( + 'xpack.observabilityAiAssistantManagement.kb.uninstallProductDoc.successNotification', + { + defaultMessage: 'The Elastic documentation was successfully uninstalled', + } + ) + ); + + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_PRODUCT_DOC_STATUS], + refetchType: 'all', + }); + }, + onError: (error) => { + toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.translate( + 'xpack.observabilityAiAssistantManagement.kb.uninstallProductDoc.errorNotification', + { + defaultMessage: 'Something went wrong while uninstalling the Elastic documentation', + } + ), + }); + }, + } + ); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts index b7c6bb089663a..67b294a5fef36 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts @@ -10,6 +10,7 @@ import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/publ import type { ManagementSetup } from '@kbn/management-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; +import type { ProductDocBasePluginStart } from '@kbn/product-doc-base-plugin/public'; import type { ObservabilityAIAssistantPublicSetup, @@ -31,6 +32,7 @@ export interface SetupDependencies { export interface StartDependencies { observabilityAIAssistant: ObservabilityAIAssistantPublicStart; serverless?: ServerlessPluginStart; + productDocBase?: ProductDocBasePluginStart; } export interface ConfigSchema { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/product_doc_entry.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/product_doc_entry.tsx new file mode 100644 index 0000000000000..668e363d071ee --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/product_doc_entry.tsx @@ -0,0 +1,171 @@ +/* + * 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, { useEffect, useState, useCallback, useMemo } from 'react'; +import { + EuiButton, + EuiDescribedFormGroup, + EuiFormRow, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '../../../hooks/use_kibana'; +import { useGetProductDocStatus } from '../../../hooks/use_get_product_doc_status'; +import { useInstallProductDoc } from '../../../hooks/use_install_product_doc'; +import { useUninstallProductDoc } from '../../../hooks/use_uninstall_product_doc'; + +export function ProductDocEntry() { + const { overlays } = useKibana().services; + + const [isInstalled, setInstalled] = useState(true); + const [isInstalling, setInstalling] = useState(false); + + const { mutateAsync: installProductDoc } = useInstallProductDoc(); + const { mutateAsync: uninstallProductDoc } = useUninstallProductDoc(); + const { status, isLoading: isStatusLoading } = useGetProductDocStatus(); + + useEffect(() => { + if (status) { + setInstalled(status.overall === 'installed'); + } + }, [status]); + + const onClickInstall = useCallback(() => { + setInstalling(true); + installProductDoc().then( + () => { + setInstalling(false); + setInstalled(true); + }, + () => { + setInstalling(false); + setInstalled(false); + } + ); + }, [installProductDoc]); + + const onClickUninstall = useCallback(() => { + overlays + .openConfirm( + i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.productDocUninstallConfirmText', + { + defaultMessage: `Are you sure you want to uninstall the Elastic documentation?`, + } + ), + { + title: i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.productDocUninstallConfirmTitle', + { + defaultMessage: `Uninstalling Elastic documentation`, + } + ), + } + ) + .then((confirmed) => { + if (confirmed) { + uninstallProductDoc().then(() => { + setInstalling(false); + setInstalled(false); + }); + } + }); + }, [overlays, uninstallProductDoc]); + + const content = useMemo(() => { + if (isStatusLoading) { + return <>; + } + if (isInstalling) { + return ( + + + + + + + ); + } + if (isInstalled) { + return ( + + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.installProductDocInstalledLabel', + { defaultMessage: 'Installed' } + )} + + + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.uninstallProductDocButtonLabel', + { defaultMessage: 'Uninstall' } + )} + + + + ); + } + return ( + + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.installProductDocButtonLabel', + { defaultMessage: 'Install' } + )} + + + + ); + }, [isInstalled, isInstalling, isStatusLoading, onClickInstall, onClickUninstall]); + + return ( + + {i18n.translate('xpack.observabilityAiAssistantManagement.settingsPage.productDocLabel', { + defaultMessage: 'Elastic documentation', + })} + + } + description={ +

+ + {i18n.translate('xpack.observabilityAiAssistantManagement.settingsPage.techPreview', { + defaultMessage: '[technical preview] ', + })} + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.productDocDescription', + { + defaultMessage: + "Install Elastic documentation to improve the assistant's efficiency.", + } + )} +

+ } + > + {content} +
+ ); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx index 831ba9ff58054..00c3fb76ae66a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx @@ -11,10 +11,12 @@ import { i18n } from '@kbn/i18n'; import { useAppContext } from '../../../hooks/use_app_context'; import { useKibana } from '../../../hooks/use_kibana'; import { UISettings } from './ui_settings'; +import { ProductDocEntry } from './product_doc_entry'; export function SettingsTab() { const { application: { navigateToApp }, + productDocBase, } = useKibana().services; const { config } = useAppContext(); @@ -108,6 +110,7 @@ export function SettingsTab() { + {productDocBase ? : undefined} ); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json index bc5cf69357dce..7b78d52c64806 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json @@ -26,7 +26,8 @@ "@kbn/logs-data-access-plugin", "@kbn/core-plugins-browser", "@kbn/ai-assistant", - "@kbn/core-plugins-server" + "@kbn/core-plugins-server", + "@kbn/product-doc-base-plugin" ], "exclude": [ "target/**/*" 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 88ef256b353e6..a6bf7e7e9d5f2 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 @@ -49,6 +49,9 @@ export default function ({ getService }: FtrProviderContext) { 'Fleet-Usage-Logger', 'Fleet-Usage-Sender', 'ML:saved-objects-sync', + 'ProductDocBase:EnsureUpToDate', + 'ProductDocBase:InstallAll', + 'ProductDocBase:UninstallAll', 'SLO:ORPHAN_SUMMARIES-CLEANUP-TASK', 'Synthetics:Clean-Up-Package-Policies', 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects', diff --git a/yarn.lock b/yarn.lock index 79da7243edd61..18b5fed51c272 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5550,6 +5550,10 @@ version "0.0.0" uid "" +"@kbn/llm-tasks-plugin@link:x-pack/plugins/ai_infra/llm_tasks": + version "0.0.0" + uid "" + "@kbn/locator-examples-plugin@link:examples/locator_examples": version "0.0.0" uid "" @@ -6034,6 +6038,14 @@ version "0.0.0" uid "" +"@kbn/product-doc-base-plugin@link:x-pack/plugins/ai_infra/product_doc_base": + version "0.0.0" + uid "" + +"@kbn/product-doc-common@link:x-pack/packages/ai-infra/product-doc-common": + version "0.0.0" + uid "" + "@kbn/profiling-data-access-plugin@link:x-pack/plugins/observability_solution/profiling_data_access": version "0.0.0" uid "" From 302652c7afa79b2f9f1ebd32d9c1e3e75ccd0e5b Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 20 Nov 2024 01:39:44 +1100 Subject: [PATCH 25/61] Authorized route migration for routes owned by @elastic/obs-ux-infra_services-team (#198196) ### Authz API migration for authorized routes This PR migrates `access:` tags used in route definitions to new security configuration. Please refer to the documentation for more information: [Authorization API](https://docs.elastic.dev/kibana-dev-docs/key-concepts/security-api-authorization) ### **Before migration:** Access control tags were defined in the `options` object of the route: ```ts router.get({ path: '/api/path', options: { tags: ['access:', 'access:'], }, ... }, handler); ``` ### **After migration:** Tags have been replaced with the more robust `security.authz.requiredPrivileges` field under `security`: ```ts router.get({ path: '/api/path', security: { authz: { requiredPrivileges: ['', ''], }, }, ... }, handler); ``` ### What to do next? 1. Review the changes in this PR. 2. You might need to update your tests to reflect the new security configuration: - If you have tests that rely on checking `access` tags. - If you have snapshot tests that include the route definition. - If you have FTR tests that rely on checking unauthorized error message. The error message changed to also include missing privileges. ## Any questions? If you have any questions or need help with API authorization, please reach out to the `@elastic/kibana-security` team. --- .../profiling/server/routes/apm.ts | 6 +++++- .../profiling/server/routes/flamechart.ts | 7 ++++++- .../profiling/server/routes/functions.ts | 7 ++++++- .../profiling/server/routes/setup/route.ts | 18 +++++++++++++++--- .../server/routes/storage_explorer/route.ts | 18 +++++++++++++++--- .../profiling/server/routes/topn.ts | 7 ++++++- 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts b/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts index e5119c17ee5da..7ad001831c0e4 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts @@ -34,8 +34,12 @@ export function registerTopNFunctionsAPMTransactionsRoute({ router.get( { path: paths.APMTransactions, + security: { + authz: { + requiredPrivileges: ['profiling', 'apm'], + }, + }, options: { - tags: ['access:profiling', 'access:apm'], timeout: { idleSocket: IDLE_SOCKET_TIMEOUT }, }, validate: { query: querySchema }, diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/flamechart.ts b/x-pack/plugins/observability_solution/profiling/server/routes/flamechart.ts index 86d384f62f609..2b318e57eb364 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/flamechart.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/flamechart.ts @@ -23,7 +23,12 @@ export function registerFlameChartSearchRoute({ router.get( { path: paths.Flamechart, - options: { tags: ['access:profiling'], timeout: { idleSocket: IDLE_SOCKET_TIMEOUT } }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, + options: { timeout: { idleSocket: IDLE_SOCKET_TIMEOUT } }, validate: { query: schema.object({ timeFrom: schema.number(), diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts b/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts index 4f30ff0c8f238..1689e707a9d80 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts @@ -34,7 +34,12 @@ export function registerTopNFunctionsSearchRoute({ router.get( { path: paths.TopNFunctions, - options: { tags: ['access:profiling'], timeout: { idleSocket: IDLE_SOCKET_TIMEOUT } }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, + options: { timeout: { idleSocket: IDLE_SOCKET_TIMEOUT } }, validate: { query: querySchema }, }, async (context, request, response) => { diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/setup/route.ts b/x-pack/plugins/observability_solution/profiling/server/routes/setup/route.ts index cbd0f6ee2170c..a5bc8d3187bda 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/setup/route.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/setup/route.ts @@ -27,7 +27,11 @@ export function registerSetupRoute({ router.get( { path: paths.HasSetupESResources, - options: { tags: ['access:profiling'] }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, validate: false, }, async (context, request, response) => { @@ -62,7 +66,11 @@ export function registerSetupRoute({ router.post( { path: paths.HasSetupESResources, - options: { tags: ['access:profiling'] }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, validate: false, }, async (context, request, response) => { @@ -166,7 +174,11 @@ export function registerSetupRoute({ router.get( { path: paths.SetupDataCollectionInstructions, - options: { tags: ['access:profiling'] }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, validate: false, }, async (context, request, response) => { diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/storage_explorer/route.ts b/x-pack/plugins/observability_solution/profiling/server/routes/storage_explorer/route.ts index 2447bfea61011..d3148fd9ff03a 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/storage_explorer/route.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/storage_explorer/route.ts @@ -29,7 +29,11 @@ export function registerStorageExplorerRoute({ router.get( { path: paths.StorageExplorerSummary, - options: { tags: ['access:profiling'] }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, validate: { query: schema.object({ indexLifecyclePhase: schema.oneOf([ @@ -112,7 +116,11 @@ export function registerStorageExplorerRoute({ router.get( { path: paths.StorageExplorerHostStorageDetails, - options: { tags: ['access:profiling'] }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, validate: { query: schema.object({ indexLifecyclePhase: schema.oneOf([ @@ -156,7 +164,11 @@ export function registerStorageExplorerRoute({ router.get( { path: paths.StorageExplorerIndicesStorageDetails, - options: { tags: ['access:profiling'] }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, validate: { query: schema.object({ indexLifecyclePhase: schema.oneOf([ diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/topn.ts b/x-pack/plugins/observability_solution/profiling/server/routes/topn.ts index 944245a9d15cc..a675cc8e4b31a 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/topn.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/topn.ts @@ -171,7 +171,12 @@ export function queryTopNCommon({ router.get( { path: pathName, - options: { tags: ['access:profiling'], timeout: { idleSocket: IDLE_SOCKET_TIMEOUT } }, + security: { + authz: { + requiredPrivileges: ['profiling'], + }, + }, + options: { timeout: { idleSocket: IDLE_SOCKET_TIMEOUT } }, validate: { query: schema.object({ timeFrom: schema.number(), From b3f27a9a46a2a0e092b520d5e1f636c981e36a2d Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 20 Nov 2024 01:43:16 +1100 Subject: [PATCH 26/61] Authorized route migration for routes owned by security-threat-hunting-investigations (#198387) ### Authz API migration for authorized routes This PR migrates `access:` tags used in route definitions to new security configuration. Please refer to the documentation for more information: [Authorization API](https://docs.elastic.dev/kibana-dev-docs/key-concepts/security-api-authorization) ### **Before migration:** Access control tags were defined in the `options` object of the route: ```ts router.get({ path: '/api/path', options: { tags: ['access:', 'access:'], }, ... }, handler); ``` ### **After migration:** Tags have been replaced with the more robust `security.authz.requiredPrivileges` field under `security`: ```ts router.get({ path: '/api/path', security: { authz: { requiredPrivileges: ['', ''], }, }, ... }, handler); ``` ### What to do next? 1. Review the changes in this PR. 2. You might need to update your tests to reflect the new security configuration: - If you have tests that rely on checking `access` tags. - If you have snapshot tests that include the route definition. - If you have FTR tests that rely on checking unauthorized error message. The error message changed to also include missing privileges. ## Any questions? If you have any questions or need help with API authorization, please reach out to the `@elastic/kibana-security` team. --- .../routes/draft_timelines/clean_draft_timelines/index.ts | 6 ++++-- .../routes/draft_timelines/get_draft_timelines/index.ts | 6 ++++-- .../server/lib/timeline/routes/notes/delete_note.ts | 6 ++++-- .../server/lib/timeline/routes/notes/get_notes.ts | 6 ++++-- .../server/lib/timeline/routes/notes/persist_note.ts | 6 ++++-- .../timeline/routes/pinned_events/persist_pinned_event.ts | 6 ++++-- .../install_prepackaged_timelines/index.ts | 6 +++++- .../lib/timeline/routes/timelines/copy_timeline/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/create_timelines/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/delete_timelines/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/export_timelines/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/get_timeline/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/get_timelines/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/import_timelines/index.ts | 6 +++++- .../lib/timeline/routes/timelines/patch_timelines/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/persist_favorite/index.ts | 6 ++++-- .../lib/timeline/routes/timelines/resolve_timeline/index.ts | 6 ++++-- 17 files changed, 70 insertions(+), 32 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/clean_draft_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/clean_draft_timelines/index.ts index 6515817f28e11..fb6ffba7995b8 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/clean_draft_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/clean_draft_timelines/index.ts @@ -31,8 +31,10 @@ export const cleanDraftTimelinesRoute = (router: SecuritySolutionPluginRouter) = router.versioned .post({ path: TIMELINE_DRAFT_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/get_draft_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/get_draft_timelines/index.ts index 1ba3167cdefae..e83d2cc839db0 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/get_draft_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/draft_timelines/get_draft_timelines/index.ts @@ -24,8 +24,10 @@ export const getDraftTimelinesRoute = (router: SecuritySolutionPluginRouter) => router.versioned .get({ path: TIMELINE_DRAFT_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts index 9e6aeb5473fc2..7308801030f4a 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts @@ -22,8 +22,10 @@ export const deleteNoteRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .delete({ path: NOTE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 0cd7853b38a1b..3a1ae1ba27e2f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -37,8 +37,10 @@ export const getNotesRoute = ( router.versioned .get({ path: NOTE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts index 2e825b4ff3a15..f9759444b26d8 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts @@ -25,8 +25,10 @@ export const persistNoteRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .patch({ path: NOTE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/pinned_events/persist_pinned_event.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/pinned_events/persist_pinned_event.ts index 74db9e58d904b..51b001c9ea29e 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/pinned_events/persist_pinned_event.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/pinned_events/persist_pinned_event.ts @@ -26,8 +26,10 @@ export const persistPinnedEventRoute = (router: SecuritySolutionPluginRouter) => router.versioned .patch({ path: PINNED_EVENT_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/index.ts index b1a6e2f781f45..99c4d95942f15 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/index.ts @@ -34,8 +34,12 @@ export const installPrepackedTimelinesRoute = ( router.versioned .post({ path: `${TIMELINE_PREPACKAGED_URL}`, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], body: { maxBytes: config.maxTimelineImportPayloadBytes, output: 'stream', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts index e795ec89dd926..502b43d4e347f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts @@ -23,8 +23,10 @@ export const copyTimelineRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .post({ path: TIMELINE_COPY_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'internal', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/index.ts index 95fb09fb28e56..a91fefc20f934 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/index.ts @@ -32,8 +32,10 @@ export const createTimelinesRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .post({ path: TIMELINE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts index 8dd476c9f4e44..07cffb3e13bf5 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts @@ -23,8 +23,10 @@ export const deleteTimelinesRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .delete({ path: TIMELINE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts index 163b212840423..5a055d54a76ce 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/export_timelines/index.ts @@ -26,8 +26,10 @@ export const exportTimelinesRoute = (router: SecuritySolutionPluginRouter, confi router.versioned .post({ path: TIMELINE_EXPORT_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timeline/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timeline/index.ts index 870955f7e8691..a1ae2178fb6fd 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timeline/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timeline/index.ts @@ -26,8 +26,10 @@ export const getTimelineRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .get({ path: TIMELINE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timelines/index.ts index 52995efcf4be1..01a3801ad8672 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/get_timelines/index.ts @@ -25,8 +25,10 @@ export const getTimelinesRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .get({ path: TIMELINES_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.ts index 59a86238941ab..f66c5456c0396 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.ts @@ -32,8 +32,12 @@ export const importTimelinesRoute = (router: SecuritySolutionPluginRouter, confi router.versioned .post({ path: `${TIMELINE_IMPORT_URL}`, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution'], body: { maxBytes: config.maxTimelineImportPayloadBytes, output: 'stream', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/patch_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/patch_timelines/index.ts index 1297f0cb1a829..7ddea9bd5ffe7 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/patch_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/patch_timelines/index.ts @@ -26,8 +26,10 @@ export const patchTimelinesRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .patch({ path: TIMELINE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/persist_favorite/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/persist_favorite/index.ts index cf66c02cf9c97..22d579229a73b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/persist_favorite/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/persist_favorite/index.ts @@ -26,8 +26,10 @@ export const persistFavoriteRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .patch({ path: TIMELINE_FAVORITE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/resolve_timeline/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/resolve_timeline/index.ts index 0afc7d21ae296..773e74faaaf46 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/resolve_timeline/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/resolve_timeline/index.ts @@ -27,8 +27,10 @@ export const resolveTimelineRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .get({ path: TIMELINE_RESOLVE_URL, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, access: 'public', }) From 674bf635a961a11b528c109573da4e719c987901 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Nov 2024 15:02:55 +0000 Subject: [PATCH 27/61] [Ownership] Assign test files to data discovery team (#200132) ## Summary Assign test files to data discovery team Contributes to: #192979 --------- Co-authored-by: Julia Rechkunova Co-authored-by: Davis McPhee --- .github/CODEOWNERS | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d7e2cf7fda612..268f1d5148d43 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1042,9 +1042,21 @@ x-pack/test_serverless/api_integration/test_suites/common/platform_security @ela /x-pack/plugins/entity_manager @elastic/obs-entities /x-pack/test/api_integration/apis/entity_manager @elastic/obs-entities + # Data Discovery -/test/plugin_functional/plugins/data_search @elastic/kibana-data-discovery +/test/functional/fixtures/es_archiver/alias @elastic/kibana-data-discovery +/test/functional/page_objects/context_page.ts @elastic/kibana-data-discovery +/test/functional/services/data_views.ts @elastic/kibana-data-discovery +/test/functional/services/saved_objects_finder.ts @elastic/kibana-data-discovery /test/plugin_functional/plugins/index_patterns @elastic/kibana-data-discovery +/test/plugin_functional/plugins/data_search @elastic/kibana-data-discovery +/test/functional/page_objects/discover_page.ts @elastic/kibana-data-discovery +/test/functional/fixtures/es_archiver/index_pattern_without_timefield @elastic/kibana-data-discovery +/test/functional/fixtures/es_archiver/huge_fields @elastic/kibana-data-discovery +/test/functional/fixtures/es_archiver/date_n* @elastic/kibana-data-discovery +/test/functional/firefox/discover.config.ts @elastic/kibana-data-discovery +/test/functional/fixtures/es_archiver/discover @elastic/kibana-data-discovery +/test/api_integration/apis/saved_queries @elastic/kibana-data-discovery /x-pack/test/api_integration/apis/kibana/kql_telemetry @elastic/kibana-data-discovery @elastic/kibana-visualizations /x-pack/test_serverless/functional/es_archives/pre_calculated_histogram @elastic/kibana-data-discovery /x-pack/test_serverless/functional/es_archives/kibana_sample_data_flights_index_pattern @elastic/kibana-data-discovery From 518dc2591f845b9e0772d367f71065cd787ad9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arturo=20Lidue=C3=B1a?= Date: Tue, 19 Nov 2024 16:16:32 +0100 Subject: [PATCH 28/61] Obs AI Assistant Fetch user instructions using user_id (#200137) ## Summary [Obs AI Assistant] Fetch user instructions using id instead of username for knowledge base instructions #192701 To avoid potential collisions when fetching data, we should query for the user id instead of the user name when getting instructions. --- .../server/service/util/get_access_query.ts | 4 +- .../tests/conversations/index.spec.ts | 147 ++++++++++-------- 2 files changed, 89 insertions(+), 62 deletions(-) diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_access_query.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_access_query.ts index f6f099d5200a8..6b654731a264b 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_access_query.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_access_query.ts @@ -19,7 +19,9 @@ export function getAccessQuery({ bool: { should: [ { term: { public: true } }, - ...(user ? [{ term: { 'user.name': user.name } }] : []), + ...(user + ? [{ term: user.id ? { 'user.id': user.id } : { 'user.name': user.name } }] + : []), ], minimum_should_match: 1, }, diff --git a/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts b/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts index de780d2f46b0e..6d509a77b42f7 100644 --- a/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts +++ b/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/common'; import { ChatFeedback } from '@kbn/observability-ai-assistant-plugin/public/analytics/schemas/chat_feedback'; import { pick } from 'lodash'; +import { parse as parseCookie } from 'tough-cookie'; +import { kbnTestConfig } from '@kbn/test'; import { createLlmProxy, isFunctionTitleRequest, @@ -17,12 +19,15 @@ import { import { interceptRequest } from '../../common/intercept_request'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { editor } from '../../../observability_ai_assistant_api_integration/common/users/users'; + export default function ApiTest({ getService, getPageObjects }: FtrProviderContext) { const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); const ui = getService('observabilityAIAssistantUI'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const retry = getService('retry'); const log = getService('log'); const telemetry = getService('kibana_ebt_ui'); @@ -35,6 +40,20 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte const flyoutService = getService('flyout'); + async function login(username: string, password: string | undefined) { + const response = await supertestWithoutAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + return parseCookie(response.headers['set-cookie'][0])!; + } + async function deleteConversations() { const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', @@ -66,78 +85,84 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte } async function createOldConversation() { - await observabilityAIAssistantAPIClient.editor({ - endpoint: 'POST /internal/observability_ai_assistant/conversation', - params: { - body: { - conversation: { - messages: [ - { - '@timestamp': '2024-04-18T14:28:50.118Z', - message: { - role: MessageRole.System, - content: - 'You are a helpful assistant for Elastic Observability. Your goal is to help the Elastic Observability users to quickly assess what is happening in their observed systems. You can help them visualise and analyze data, investigate their systems, perform root cause analysis or identify optimisation opportunities.\n\nIt\'s very important to not assume what the user is meaning. Ask them for clarification if needed.\n\nIf you are unsure about which function should be used and with what arguments, ask the user for clarification or confirmation.\n\nIn KQL ("kqlFilter")) escaping happens with double quotes, not single quotes. Some characters that need escaping are: \':()\\ /". Always put a field value in double quotes. Best: service.name:"opbeans-go". Wrong: service.name:opbeans-go. This is very important!\n\nYou can use Github-flavored Markdown in your responses. If a function returns an array, consider using a Markdown table to format the response.\n\nNote that ES|QL (the Elasticsearch Query Language which is a new piped language) is the preferred query language.\n\nYou MUST use the "query" function when the user wants to:\n- visualize data\n- run any arbitrary query\n- breakdown or filter ES|QL queries that are displayed on the current page\n- convert queries from another language to ES|QL\n- asks general questions about ES|QL\n\nDO NOT UNDER ANY CIRCUMSTANCES generate ES|QL queries or explain anything about the ES|QL query language yourself.\nDO NOT UNDER ANY CIRCUMSTANCES try to correct an ES|QL query yourself - always use the "query" function for this.\n\nDO NOT UNDER ANY CIRCUMSTANCES USE ES|QL syntax (`service.name == "foo"`) with "kqlFilter" (`service.name:"foo"`).\n\nEven if the "context" function was used before that, follow it up with the "query" function. If a query fails, do not attempt to correct it yourself. Again you should call the "query" function,\neven if it has been called before.\n\nWhen the "visualize_query" function has been called, a visualization has been displayed to the user. DO NOT UNDER ANY CIRCUMSTANCES follow up a "visualize_query" function call with your own visualization attempt.\nIf the "execute_query" function has been called, summarize these results for the user. The user does not see a visualization in this case.\n\nYou MUST use the get_dataset_info function function before calling the "query" or "changes" function.\n\nIf a function requires an index, you MUST use the results from the dataset info functions.\n\n\n\nThe user is able to change the language which they want you to reply in on the settings page of the AI Assistant for Observability, which can be found in the Stack Management app under the option AI Assistants.\nIf the user asks how to change the language, reply in the same language the user asked in.You do not have a working memory. If the user expects you to remember the previous conversations, tell them they can set up the knowledge base.', - }, + const { password } = kbnTestConfig.getUrlParts(); + const sessionCookie = await login(editor.username, password); + const endpoint = '/internal/observability_ai_assistant/conversation'; + const cookie = sessionCookie.cookieString(); + const params = { + body: { + conversation: { + messages: [ + { + '@timestamp': '2024-04-18T14:28:50.118Z', + message: { + role: MessageRole.System, + content: + 'You are a helpful assistant for Elastic Observability. Your goal is to help the Elastic Observability users to quickly assess what is happening in their observed systems. You can help them visualise and analyze data, investigate their systems, perform root cause analysis or identify optimisation opportunities.\n\nIt\'s very important to not assume what the user is meaning. Ask them for clarification if needed.\n\nIf you are unsure about which function should be used and with what arguments, ask the user for clarification or confirmation.\n\nIn KQL ("kqlFilter")) escaping happens with double quotes, not single quotes. Some characters that need escaping are: \':()\\ /". Always put a field value in double quotes. Best: service.name:"opbeans-go". Wrong: service.name:opbeans-go. This is very important!\n\nYou can use Github-flavored Markdown in your responses. If a function returns an array, consider using a Markdown table to format the response.\n\nNote that ES|QL (the Elasticsearch Query Language which is a new piped language) is the preferred query language.\n\nYou MUST use the "query" function when the user wants to:\n- visualize data\n- run any arbitrary query\n- breakdown or filter ES|QL queries that are displayed on the current page\n- convert queries from another language to ES|QL\n- asks general questions about ES|QL\n\nDO NOT UNDER ANY CIRCUMSTANCES generate ES|QL queries or explain anything about the ES|QL query language yourself.\nDO NOT UNDER ANY CIRCUMSTANCES try to correct an ES|QL query yourself - always use the "query" function for this.\n\nDO NOT UNDER ANY CIRCUMSTANCES USE ES|QL syntax (`service.name == "foo"`) with "kqlFilter" (`service.name:"foo"`).\n\nEven if the "context" function was used before that, follow it up with the "query" function. If a query fails, do not attempt to correct it yourself. Again you should call the "query" function,\neven if it has been called before.\n\nWhen the "visualize_query" function has been called, a visualization has been displayed to the user. DO NOT UNDER ANY CIRCUMSTANCES follow up a "visualize_query" function call with your own visualization attempt.\nIf the "execute_query" function has been called, summarize these results for the user. The user does not see a visualization in this case.\n\nYou MUST use the get_dataset_info function function before calling the "query" or "changes" function.\n\nIf a function requires an index, you MUST use the results from the dataset info functions.\n\n\n\nThe user is able to change the language which they want you to reply in on the settings page of the AI Assistant for Observability, which can be found in the Stack Management app under the option AI Assistants.\nIf the user asks how to change the language, reply in the same language the user asked in.You do not have a working memory. If the user expects you to remember the previous conversations, tell them they can set up the knowledge base.', }, - { - '@timestamp': '2024-04-18T14:29:01.615Z', - message: { - content: 'What are SLOs?', - role: MessageRole.User, - }, - }, - { - '@timestamp': '2024-04-18T14:29:01.876Z', - message: { - role: MessageRole.Assistant, - content: '', - function_call: { - name: 'context', - arguments: '{}', - trigger: MessageRole.Assistant, - }, - }, + }, + { + '@timestamp': '2024-04-18T14:29:01.615Z', + message: { + content: 'What are SLOs?', + role: MessageRole.User, }, - { - '@timestamp': '2024-04-18T14:29:01.876Z', - message: { - content: - '{"screen_description":"The user is looking at http://localhost:5601/ftw/app/observabilityAIAssistant/conversations/new. The current time range is 2024-04-18T14:13:49.815Z - 2024-04-18T14:28:49.815Z.","learnings":[]}', + }, + { + '@timestamp': '2024-04-18T14:29:01.876Z', + message: { + role: MessageRole.Assistant, + content: '', + function_call: { name: 'context', - role: MessageRole.User, + arguments: '{}', + trigger: MessageRole.Assistant, }, }, - { - '@timestamp': '2024-04-18T14:29:22.945Z', - message: { - content: - "SLOs, or Service Level Objectives, are a key part of the Site Reliability Engineering (SRE) methodology. They are a target value or range of values for a service level that is measured by an SLI (Service Level Indicator). \n\nAn SLO is a goal for how often and how much you want your service to meet a particular SLI. For example, you might have an SLO that your service should be up and running 99.9% of the time. \n\nSLOs are important because they set clear expectations for your team and your users about the level of service you aim to provide. They also help you make decisions about where to focus your efforts: if you're meeting your SLOs, you can focus on building new features; if you're not meeting your SLOs, you need to focus on improving reliability. \n\nIn Elastic Observability, you can define and monitor your SLOs to ensure your services are meeting their targets.", - function_call: { - name: '', - arguments: '', - trigger: MessageRole.Assistant, - }, - role: MessageRole.Assistant, - }, + }, + { + '@timestamp': '2024-04-18T14:29:01.876Z', + message: { + content: + '{"screen_description":"The user is looking at http://localhost:5601/ftw/app/observabilityAIAssistant/conversations/new. The current time range is 2024-04-18T14:13:49.815Z - 2024-04-18T14:28:49.815Z.","learnings":[]}', + name: 'context', + role: MessageRole.User, }, - ], - conversation: { - title: 'My old conversation', - token_count: { - completion: 1, - prompt: 1, - total: 2, + }, + { + '@timestamp': '2024-04-18T14:29:22.945Z', + message: { + content: + "SLOs, or Service Level Objectives, are a key part of the Site Reliability Engineering (SRE) methodology. They are a target value or range of values for a service level that is measured by an SLI (Service Level Indicator). \n\nAn SLO is a goal for how often and how much you want your service to meet a particular SLI. For example, you might have an SLO that your service should be up and running 99.9% of the time. \n\nSLOs are important because they set clear expectations for your team and your users about the level of service you aim to provide. They also help you make decisions about where to focus your efforts: if you're meeting your SLOs, you can focus on building new features; if you're not meeting your SLOs, you need to focus on improving reliability. \n\nIn Elastic Observability, you can define and monitor your SLOs to ensure your services are meeting their targets.", + function_call: { + name: '', + arguments: '', + trigger: MessageRole.Assistant, + }, + role: MessageRole.Assistant, }, }, - '@timestamp': '2024-04-18T14:29:22.948', - public: false, - numeric_labels: {}, - labels: {}, + ], + conversation: { + title: 'My old conversation', + token_count: { + completion: 1, + prompt: 1, + total: 2, + }, }, + '@timestamp': '2024-04-18T14:29:22.948', + public: false, + numeric_labels: {}, + labels: {}, }, }, - }); + }; + await supertestWithoutAuth + .post(endpoint) + .set('kbn-xsrf', 'xxx') + .set('Cookie', cookie) + .send(params.body); } describe('Conversations', () => { From 48158d49269e8134f99faa822be1acd53a04326f Mon Sep 17 00:00:00 2001 From: Sonia Sanz Vivas Date: Tue, 19 Nov 2024 16:21:23 +0100 Subject: [PATCH 29/61] Fix flashing banner when creating pipeline (#199786) Closes [#197810](https://github.com/elastic/kibana/issues/197810) ## Summary The `isValid` verification wasn't taken into account if the form was in `isSubmitting` state. ![flashing banner](https://github.com/user-attachments/assets/6f9173ff-7f2c-46a5-99cc-31fdd699404e) --- .../application/components/pipeline_form/pipeline_form.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index 80c43af7b7d4d..b70e767de29b4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -175,7 +175,7 @@ export const PipelineForm: React.FunctionComponent = ({
{/* Request error */} @@ -244,7 +244,6 @@ export const PipelineForm: React.FunctionComponent = ({ - {/* ES request flyout */} {isRequestVisible ? ( Date: Tue, 19 Nov 2024 16:31:12 +0100 Subject: [PATCH 30/61] [APM] Unskip feature flag test (#200596) closes [#198998](https://github.com/elastic/kibana/issues/198998) ## Summary Just unskips he APM feature flag serverless tests. --- .../apm_api_integration/common/apm_api_supertest.ts | 2 +- .../observability/apm_api_integration/feature_flags.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts index 19f102335d99f..3b05b5d08d29d 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/common/apm_api_supertest.ts @@ -46,7 +46,7 @@ export function createApmApiClient(st: supertest.Agent) { .set('Content-type', 'multipart/form-data'); for (const field of fields) { - await formDataRequest.field(field[0], field[1]); + void formDataRequest.field(field[0], field[1]); } res = await formDataRequest; diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts index 88096d6258e27..15af0d68d8db7 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/feature_flags.ts @@ -77,9 +77,7 @@ export default function ({ getService }: APMFtrContextProvider) { const svlUserManager = getService('svlUserManager'); const svlCommonApi = getService('svlCommonApi'); - // https://github.com/elastic/kibana/pull/190690 - // skipping since "rejects requests to list source maps" fails with 400 - describe.skip('apm feature flags', () => { + describe('apm feature flags', () => { let roleAuthc: RoleCredentials; let internalReqHeader: InternalRequestHeader; From ee49986876b626383d79a7b4796e79bdf67fff84 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 19 Nov 2024 16:35:10 +0100 Subject: [PATCH 31/61] [Visual Refresh] Add Borealis theme (#199993) ## Summary This PR introduces the first internal version of the new theme `Borealis` and ensures that: - themes can be switched between "Amsterdam" and "Borealis" - theme-specific Sass files are available and can be loaded with `KBN_OPTIMIZER_THEMES=experimental` - legacy JSON variable usage accounts for both themes - static template styles account for both themes ## Running locally ```yml // kibana.dev.yml or kibana.yml uiSettings.experimental.themeSwitcherEnabled: true ``` Start kibana ``` KBN_OPTIMIZER_THEMES='v8light,v8dark,borealislight,borealisdark' yarn start or KBN_OPTIMIZER_THEMES=experimental yarn start ``` ### 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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --------- Co-authored-by: Tomasz Kajtoch --- package.json | 3 +- .../src/views/styles.tsx | 37 +++++++++++---- .../src/views/template.tsx | 6 ++- .../core-ui-settings-common/src/theme.ts | 5 ++ packages/kbn-eslint-config/.eslintrc.js | 17 ++++--- packages/kbn-optimizer/limits.yml | 4 +- .../kbn-ui-shared-deps-npm/webpack.config.js | 6 ++- .../kbn-ui-shared-deps-src/src/definitions.js | 1 + packages/kbn-ui-shared-deps-src/src/entry.js | 1 + packages/kbn-ui-theme/src/theme.ts | 46 ++++++++++++++----- packages/react/kibana_context/common/theme.ts | 4 ++ .../core_app/_globals_borealisdark.scss | 9 ++++ .../core_app/_globals_borealislight.scss | 9 ++++ src/dev/license_checker/config.ts | 3 +- .../components/_solutions_section.scss | 2 +- .../heatmap_style_editor.test.tsx.snap | 6 +-- .../__snapshots__/prompt_page.test.tsx.snap | 4 +- .../unauthenticated_page.test.tsx.snap | 4 +- .../reset_session_page.test.tsx.snap | 4 +- .../components/endpoint/link_to_app.tsx | 2 +- .../test/functional/apps/infra/home_page.ts | 4 +- .../functional/apps/lens/group4/chart_data.ts | 2 +- .../functional/apps/lens/group5/heatmap.ts | 10 ++-- yarn.lock | 22 +++++++-- 24 files changed, 155 insertions(+), 56 deletions(-) create mode 100644 src/core/public/styles/core_app/_globals_borealisdark.scss create mode 100644 src/core/public/styles/core_app/_globals_borealislight.scss diff --git a/package.json b/package.json index b67d4b90fdf95..eed5b3a9b61cb 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,8 @@ "@elastic/ecs": "^8.11.1", "@elastic/elasticsearch": "^8.15.2", "@elastic/ems-client": "8.5.3", - "@elastic/eui": "97.3.1", + "@elastic/eui": "97.3.1-borealis.2", + "@elastic/eui-theme-borealis": "0.0.2", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "^1.2.3", "@elastic/numeral": "^2.5.1", diff --git a/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx b/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx index ceeb6f4b7f9e2..54e8559ad25c1 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx @@ -8,17 +8,18 @@ */ import React, { FC } from 'react'; -import type { DarkModeValue } from '@kbn/core-ui-settings-common'; +import { type DarkModeValue, ThemeName } from '@kbn/core-ui-settings-common'; interface Props { darkMode: DarkModeValue; + themeName: ThemeName; stylesheetPaths: string[]; } -export const Styles: FC = ({ darkMode, stylesheetPaths }) => { +export const Styles: FC = ({ darkMode, themeName, stylesheetPaths }) => { return ( <> - {darkMode !== 'system' && } + {darkMode !== 'system' && } {stylesheetPaths.map((path) => ( ))} @@ -26,7 +27,27 @@ export const Styles: FC = ({ darkMode, stylesheetPaths }) => { ); }; -const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { +const InlineStyles: FC<{ darkMode: boolean; themeName: ThemeName }> = ({ darkMode, themeName }) => { + const getThemeStyles = (theme: ThemeName) => { + if (theme === 'borealis') { + return { + pageBackground: darkMode ? '#07101F' : '#F6F9FC', // colors.body + welcomeText: darkMode ? '#8E9FBC' : '#5A6D8C', // colors.subduedText + progress: darkMode ? '#172336' : '#ECF1F9', // colors.lightestShade + progressBefore: darkMode ? '#599DFF' : '#0B64DD', // colors.primary + }; + } + + return { + pageBackground: darkMode ? '#141519' : '#F8FAFD', + welcomeText: darkMode ? '#98A2B3' : '#69707D', + progress: darkMode ? '#25262E' : '#F5F7FA', + progressBefore: darkMode ? '#1BA9F5' : '#006DE4', + }; + }; + + const themeStyles = getThemeStyles(themeName); + // must be kept in sync with // packages/core/apps/core-apps-server-internal/assets/legacy_theme.js /* eslint-disable react/no-danger */ @@ -36,19 +57,19 @@ const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { __html: ` html { - background-color: ${darkMode ? '#141519' : '#F8FAFD'} + background-color: ${themeStyles.pageBackground} } .kbnWelcomeText { - color: ${darkMode ? '#98A2B3' : '#69707D'}; + color: ${themeStyles.welcomeText}; } .kbnProgress { - background-color: ${darkMode ? '#25262E' : '#F5F7FA'}; + background-color: ${themeStyles.progress}; } .kbnProgress:before { - background-color: ${darkMode ? '#1BA9F5' : '#006DE4'}; + background-color: ${themeStyles.progressBefore}; } `, diff --git a/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx b/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx index fdbade121445d..d3556287a0333 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx @@ -56,7 +56,11 @@ export const Template: FunctionComponent = ({ {/* Inject EUI reset and global styles before all other component styles */} - + {scriptPaths.map((path) => (

Some Title

Some Body
Action#1
Action#2
"`; +exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; -exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; +exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; diff --git a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap index 80a7e7a24e1e9..ab94f2c2efc8d 100644 --- a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; +exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; -exports[`UnauthenticatedPage renders as expected with custom title 1`] = `"My Company NameMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; +exports[`UnauthenticatedPage renders as expected with custom title 1`] = `"My Company NameMockedFonts

We hit an authentication error

Try logging in again, and if the problem persists, contact your system administrator.

"`; diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index e7a902015afa7..fcab54e925cfb 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; -exports[`ResetSessionPage renders as expected with custom page title 1`] = `"My Company NameMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected with custom page title 1`] = `"My Company NameMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx index 2adecd4a42391..74b18c32c63a0 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx @@ -51,7 +51,7 @@ export const LinkToApp = memo( {children} ) : ( - + {children} )} diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index f36b3394e2a89..fc937afc3f3c9 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -426,8 +426,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(nodesWithValue).to.eql([ { name: 'host-5', value: 10, color: '#6092c0' }, { name: 'host-4', value: 30, color: '#9ab6d5' }, - { name: 'host-1', value: 50, color: '#f1d9b9' }, - { name: 'host-2', value: 70, color: '#eba47a' }, + { name: 'host-1', value: 50, color: '#f6e0b9' }, + { name: 'host-2', value: 70, color: '#eda77a' }, { name: 'host-3', value: 90, color: '#e7664c' }, ]); }); diff --git a/x-pack/test/functional/apps/lens/group4/chart_data.ts b/x-pack/test/functional/apps/lens/group4/chart_data.ts index 3b3a51c289473..fc922f8d2df17 100644 --- a/x-pack/test/functional/apps/lens/group4/chart_data.ts +++ b/x-pack/test/functional/apps/lens/group4/chart_data.ts @@ -117,7 +117,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { key: '5,722.775 - 8,529.22', name: '5,722.775 - 8,529.22', color: '#6092c0' }, { key: '8,529.22 - 11,335.665', name: '8,529.22 - 11,335.665', color: '#a8bfda' }, { key: '11,335.665 - 14,142.11', name: '11,335.665 - 14,142.11', color: '#ebeff5' }, - { key: '14,142.11 - 16,948.555', name: '14,142.11 - 16,948.555', color: '#ecb385' }, + { key: '14,142.11 - 16,948.555', name: '14,142.11 - 16,948.555', color: '#efb785' }, { key: '≥ 16,948.555', name: '≥ 16,948.555', color: '#e7664c' }, ]); }); diff --git a/x-pack/test/functional/apps/lens/group5/heatmap.ts b/x-pack/test/functional/apps/lens/group5/heatmap.ts index 7abcba0cb0780..a61afa2d24d8a 100644 --- a/x-pack/test/functional/apps/lens/group5/heatmap.ts +++ b/x-pack/test/functional/apps/lens/group5/heatmap.ts @@ -58,7 +58,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { key: '5,722.775 - 8,529.22', name: '5,722.775 - 8,529.22', color: '#6092c0' }, { key: '8,529.22 - 11,335.665', name: '8,529.22 - 11,335.665', color: '#a8bfda' }, { key: '11,335.665 - 14,142.11', name: '11,335.665 - 14,142.11', color: '#ebeff5' }, - { key: '14,142.11 - 16,948.555', name: '14,142.11 - 16,948.555', color: '#ecb385' }, + { key: '14,142.11 - 16,948.555', name: '14,142.11 - 16,948.555', color: '#efb785' }, { key: '≥ 16,948.555', name: '≥ 16,948.555', color: '#e7664c' }, ]); }); @@ -80,7 +80,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { key: '7,125.997 - 8,529.22', name: '7,125.997 - 8,529.22', color: '#6092c0' }, { key: '8,529.22 - 11,335.665', name: '8,529.22 - 11,335.665', color: '#a8bfda' }, { key: '11,335.665 - 14,142.11', name: '11,335.665 - 14,142.11', color: '#ebeff5' }, - { key: '14,142.11 - 16,948.555', name: '14,142.11 - 16,948.555', color: '#ecb385' }, + { key: '14,142.11 - 16,948.555', name: '14,142.11 - 16,948.555', color: '#efb785' }, { key: '≥ 16,948.555', name: '≥ 16,948.555', color: '#e7664c' }, ]); }); @@ -94,7 +94,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { key: '7,125.99 - 8,529.2', name: '7,125.99 - 8,529.2', color: '#6092c0' }, { key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' }, { key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' }, - { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' }, + { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#efb785' }, { color: '#e7664c', key: '≥ 16,948.55', @@ -115,7 +115,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { key: '0 - 8,529.2', name: '0 - 8,529.2', color: '#6092c0' }, { key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' }, { key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' }, - { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' }, + { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#efb785' }, { key: '≥ 16,948.55', name: '≥ 16,948.55', color: '#e7664c' }, ]); }); @@ -133,7 +133,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { key: '5,722.775 - 8,529.2', name: '5,722.775 - 8,529.2', color: '#6092c0' }, { key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' }, { key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' }, - { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' }, + { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#efb785' }, { key: '≥ 16,948.55', name: '≥ 16,948.55', color: '#e7664c' }, ]); // assert the cell has the correct coloring despite the legend rounding diff --git a/yarn.lock b/yarn.lock index 18b5fed51c272..5b3b6f246ff42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1748,11 +1748,25 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@97.3.1": - version "97.3.1" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-97.3.1.tgz#b0f07c603042bd359544b41829507e65f4fa3cd2" - integrity sha512-zJs3aaO6qjTdxJM2mPahcqaC6FfaC34yTc3qpQq7+Cbhw2xGrwx8bAfIzhttLU87mwgr59Sqv9Ojvwk8c3js7A== +"@elastic/eui-theme-borealis@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@elastic/eui-theme-borealis/-/eui-theme-borealis-0.0.2.tgz#4b65f13073b1887a12641063ace96539fa923674" + integrity sha512-ekePJ+V9UMCUDqjNLECjM+Vi/qHkJcu6lhm1GenUFs3awPxaLhvasb3pN++qnWYkXWo90vmZER62MTHpxlQyQA== + +"@elastic/eui-theme-common@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@elastic/eui-theme-common/-/eui-theme-common-0.0.2.tgz#3da6078a5d255c5740423d26409e5e06536a5db3" + integrity sha512-tIyXrylrLhmOWiRbxuJSiHHVJpt4fVd5frzhUGoSN2frobOT9RLh8Klzyd4kmHasZ7bB1vETPR5fytqgocRvdA== + dependencies: + "@types/lodash" "^4.14.202" + lodash "^4.17.21" + +"@elastic/eui@97.3.1-borealis.2": + version "97.3.1-borealis.2" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-97.3.1-borealis.2.tgz#32d9616ddbab11ef6e97739cf728a667220ca74c" + integrity sha512-j0WsE+WWtV3eEbRqyjr8hJ1swQIbCEGc9iViMtDK/XeVCVqs++dJE/+jPdjharMjXLrstOr0cx0uvtsH6OWTUw== dependencies: + "@elastic/eui-theme-common" "0.0.2" "@hello-pangea/dnd" "^16.6.0" "@types/lodash" "^4.14.202" "@types/numeral" "^2.0.5" From ddf324a9a055bf9ab5a6ba3e9a5f7c36d1e7f9ae Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 19 Nov 2024 08:57:56 -0700 Subject: [PATCH 32/61] [ML] Single Metric Viewer embeddable: fix job refetch on error (#199726) ## Summary This PR ensures that the job fetch is not reattempted continuously when errors are encountered. To test, add a Single Metric Viewer panel to a dashboard, then delete the job the panel depends on. You can then refresh the browser and see the error message about the job not being found. Error panel for generic error and for no known job error: image ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Elastic Machine --- .../single_metric_viewer.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx index 9d2ba88493774..39f22e2e988db 100644 --- a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx +++ b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx @@ -20,6 +20,7 @@ import type { MlJob, MlJobStats } from '@elastic/elasticsearch/lib/api/types'; import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; import type { TimeRangeBounds } from '@kbn/ml-time-buckets'; import usePrevious from 'react-use/lib/usePrevious'; +import { extractErrorProperties } from '@kbn/ml-error-utils'; import { tz } from 'moment'; import { pick, throttle } from 'lodash'; import type { MlDependencies } from '../../application/app'; @@ -43,10 +44,17 @@ interface AppStateZoom { to?: string; } -const errorMessage = i18n.translate('xpack.ml.singleMetricViewerEmbeddable.errorMessage"', { +const basicErrorMessage = i18n.translate('xpack.ml.singleMetricViewerEmbeddable.errorMessage"', { defaultMessage: 'Unable to load the ML single metric viewer data', }); +const jobNotFoundErrorMessage = i18n.translate( + 'xpack.ml.singleMetricViewerEmbeddable.jobNotFoundErrorMessage"', + { + defaultMessage: 'No known job with the selected id', + } +); + export type SingleMetricViewerSharedComponent = FC; /** @@ -72,7 +80,7 @@ export interface SingleMetricViewerProps { */ lastRefresh?: number; onRenderComplete?: () => void; - onError?: (error: Error) => void; + onError?: (error?: Error) => void; onForecastIdChange?: (forecastId: string | undefined) => void; uuid: string; } @@ -112,6 +120,7 @@ const SingleMetricViewerWrapper: FC = ({ const [selectedJobWrapper, setSelectedJobWrapper] = useState< { job: MlJob; stats: MlJobStats } | undefined >(); + const [errorEncountered, setErrorEncountered] = useState(); const isMounted = useMountedState(); const { mlApi, mlTimeSeriesExplorerService, toastNotificationService } = mlServices; @@ -125,6 +134,16 @@ const SingleMetricViewerWrapper: FC = ({ const previousRefresh = usePrevious(lastRefresh ?? 0); + useEffect( + function resetErrorOnJobChange() { + // Calling onError to clear any previous error + setErrorEncountered(undefined); + onError?.(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedJobId] + ); + useEffect( function setUpSelectedJob() { async function fetchSelectedJob() { @@ -136,19 +155,26 @@ const SingleMetricViewerWrapper: FC = ({ ]); setSelectedJobWrapper({ job: jobs[0], stats: jobStats[0] }); } catch (e) { + const error = extractErrorProperties(e); + // Could get 404 because job has been deleted and also avoid infinite refetches on any error + setErrorEncountered(error.statusCode); if (onError) { - onError(new Error(errorMessage)); + onError( + new Error(errorEncountered === 404 ? jobNotFoundErrorMessage : basicErrorMessage) + ); } } } } - if (isMounted() === false) { + if (isMounted() === false || errorEncountered !== undefined) { return; } fetchSelectedJob(); }, - [selectedJobId, mlApi, isMounted, onError] + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedJobId, isMounted, errorEncountered] ); + // eslint-disable-next-line react-hooks/exhaustive-deps const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { From eb504e642a7ecd76d4d0ab3423a0957bf4d66475 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Tue, 19 Nov 2024 17:36:59 +0100 Subject: [PATCH 33/61] [Infra] Fix deprecated usage of dataview `title` (#200751) Closes #200698 ## Summary This PR replaces the usage of the deprecated dataview `title` with `getIndexPattern` --- .../infra/public/pages/logs/settings/validation_errors.ts | 8 ++++---- .../inventory_view/hooks/use_waffle_filters.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/validation_errors.ts b/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/validation_errors.ts index e6b375efdaab7..b769bb68f8d2a 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/validation_errors.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/validation_errors.ts @@ -91,7 +91,7 @@ export const validateIndexPatternIsTimeBased = (indexPattern: DataView): FormVal : [ { type: 'missing_timestamp_field' as const, - indexPatternTitle: indexPattern.title, + indexPatternTitle: indexPattern.getIndexPattern(), }, ]; @@ -104,14 +104,14 @@ export const validateIndexPatternHasStringMessageField = ( return [ { type: 'missing_message_field' as const, - indexPatternTitle: indexPattern.title, + indexPatternTitle: indexPattern.getIndexPattern(), }, ]; } else if (messageField.type !== KBN_FIELD_TYPES.STRING) { return [ { type: 'invalid_message_field_type' as const, - indexPatternTitle: indexPattern.title, + indexPatternTitle: indexPattern.getIndexPattern(), }, ]; } else { @@ -124,7 +124,7 @@ export const validateIndexPatternIsntRollup = (indexPattern: DataView): FormVali ? [ { type: 'rollup_index_pattern' as const, - indexPatternTitle: indexPattern.title, + indexPatternTitle: indexPattern.getIndexPattern(), }, ] : []; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts index 87aaad06abc96..7fe7b8b3fe18c 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts @@ -21,11 +21,11 @@ jest.mock('react-router-dom', () => ({ const mockDataView = { id: 'mock-id', - title: 'mock-title', timeFieldName: TIMESTAMP_FIELD, isPersisted: () => false, getName: () => 'mock-data-view', toSpec: () => ({}), + getIndexPattern: () => 'mock-title', } as jest.Mocked; jest.mock('../../../../containers/metrics_source', () => ({ From 236586ce0cd135450d351cb61c6930752e0ad9ea Mon Sep 17 00:00:00 2001 From: Milosz Marcinkowski <38698566+miloszmarcinkowski@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:41:31 +0100 Subject: [PATCH 34/61] Revert "[APM] Set explicit access options for APM public APIs (#197435)" (#200750) closes #200742 This PR reverts changes introduced in [PR](https://github.com/elastic/kibana/pull/197435) to avoid duplicating logic. --- .../apm/server/routes/agent_keys/route.ts | 5 +---- .../apm/server/routes/fleet/route.ts | 2 +- .../apm/server/routes/services/route.ts | 3 +-- .../routes/settings/agent_configuration/route.ts | 12 +++++------- .../apm/server/routes/source_maps/route.ts | 5 ++--- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/observability_solution/apm/server/routes/agent_keys/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/agent_keys/route.ts index a296b7f8be284..d8c2cd70768c4 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/agent_keys/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/agent_keys/route.ts @@ -91,10 +91,7 @@ const invalidateAgentKeyRoute = createApmServerRoute({ const createAgentKeyRoute = createApmServerRoute({ endpoint: 'POST /api/apm/agent_keys 2023-10-31', - options: { - tags: ['access:apm', 'access:apm_settings_write', 'oas-tag:APM agent keys'], - access: 'public', - }, + options: { tags: ['access:apm', 'access:apm_settings_write', 'oas-tag:APM agent keys'] }, params: t.type({ body: t.type({ name: t.string, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/fleet/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/fleet/route.ts index 1355460cc1836..05b74b3fc9c42 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/fleet/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/fleet/route.ts @@ -65,7 +65,7 @@ const fleetAgentsRoute = createApmServerRoute({ const saveApmServerSchemaRoute = createApmServerRoute({ endpoint: 'POST /api/apm/fleet/apm_server_schema 2023-10-31', - options: { tags: ['access:apm', 'access:apm_write'], access: 'public' }, + options: { tags: ['access:apm', 'access:apm_write'] }, params: t.type({ body: t.type({ schema: t.record(t.string, t.unknown), diff --git a/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts index eb810fae50323..da2a506e3ae3f 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts @@ -395,7 +395,7 @@ const serviceAnnotationsRoute = createApmServerRoute({ }), query: t.intersection([environmentRt, rangeRt]), }), - options: { tags: ['access:apm', 'oas-tag:APM annotations'], access: 'public' }, + options: { tags: ['access:apm', 'oas-tag:APM annotations'] }, handler: async (resources): Promise => { const apmEventClient = await getApmEventClient(resources); const { params, plugins, context, request, logger, config } = resources; @@ -440,7 +440,6 @@ const serviceAnnotationsCreateRoute = createApmServerRoute({ endpoint: 'POST /api/apm/services/{serviceName}/annotation 2023-10-31', options: { tags: ['access:apm', 'access:apm_write', 'oas-tag:APM annotations'], - access: 'public', }, params: t.type({ path: t.type({ diff --git a/x-pack/plugins/observability_solution/apm/server/routes/settings/agent_configuration/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/settings/agent_configuration/route.ts index bc8109dfa2808..aaf8fb2c48681 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/settings/agent_configuration/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/settings/agent_configuration/route.ts @@ -39,7 +39,7 @@ function throwNotFoundIfAgentConfigNotAvailable(featureFlags: ApmFeatureFlags): // get list of configurations const agentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration 2023-10-31', - options: { tags: ['access:apm'], access: 'public' }, + options: { tags: ['access:apm'] }, handler: async ( resources ): Promise<{ @@ -68,7 +68,7 @@ const getSingleAgentConfigurationRoute = createApmServerRoute({ params: t.partial({ query: serviceRt, }), - options: { tags: ['access:apm'], access: 'public' }, + options: { tags: ['access:apm'] }, handler: async (resources): Promise => { throwNotFoundIfAgentConfigNotAvailable(resources.featureFlags); @@ -100,7 +100,6 @@ const deleteAgentConfigurationRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/agent-configuration 2023-10-31', options: { tags: ['access:apm', 'access:apm_settings_write'], - access: 'public', }, params: t.type({ body: t.type({ @@ -157,7 +156,6 @@ const createOrUpdateAgentConfigurationRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/agent-configuration 2023-10-31', options: { tags: ['access:apm', 'access:apm_settings_write'], - access: 'public', }, params: t.intersection([ t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), @@ -226,7 +224,7 @@ const agentConfigurationSearchRoute = createApmServerRoute({ params: t.type({ body: searchParamsRt, }), - options: { tags: ['access:apm'], disableTelemetry: true, access: 'public' }, + options: { tags: ['access:apm'], disableTelemetry: true }, handler: async ( resources ): Promise | null> => { @@ -288,7 +286,7 @@ const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({ params: t.partial({ query: t.partial({ serviceName: t.string }), }), - options: { tags: ['access:apm'], access: 'public' }, + options: { tags: ['access:apm'] }, handler: async ( resources ): Promise<{ @@ -329,7 +327,7 @@ const agentConfigurationAgentNameRoute = createApmServerRoute({ params: t.type({ query: t.type({ serviceName: t.string }), }), - options: { tags: ['access:apm'], access: 'public' }, + options: { tags: ['access:apm'] }, handler: async (resources): Promise<{ agentName: string | undefined }> => { throwNotFoundIfAgentConfigNotAvailable(resources.featureFlags); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/source_maps/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/source_maps/route.ts index f1f7f3def93ab..bc92c06416204 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/source_maps/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/source_maps/route.ts @@ -49,7 +49,7 @@ function throwNotImplementedIfSourceMapNotAvailable(featureFlags: ApmFeatureFlag const listSourceMapRoute = createApmServerRoute({ endpoint: 'GET /api/apm/sourcemaps 2023-10-31', - options: { tags: ['access:apm'], access: 'public' }, + options: { tags: ['access:apm'] }, params: t.partial({ query: t.partial({ page: toNumberRt, @@ -87,7 +87,6 @@ const uploadSourceMapRoute = createApmServerRoute({ options: { tags: ['access:apm', 'access:apm_write'], body: { accepts: ['multipart/form-data'] }, - access: 'public', }, params: t.type({ body: t.type({ @@ -160,7 +159,7 @@ const uploadSourceMapRoute = createApmServerRoute({ const deleteSourceMapRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/sourcemaps/{id} 2023-10-31', - options: { tags: ['access:apm', 'access:apm_write'], access: 'public' }, + options: { tags: ['access:apm', 'access:apm_write'] }, params: t.type({ path: t.type({ id: t.string, From 8be679ae20fb7f5c41ccb3554b6db3dc2cad0678 Mon Sep 17 00:00:00 2001 From: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> Date: Tue, 19 Nov 2024 18:49:50 +0200 Subject: [PATCH 35/61] [Mappings Editor] Add support for synthetic _source (#199854) Closes https://github.com/elastic/kibana/issues/198621 ## Summary This PR adds support for the synthetic _source field in the mappings Advanced options. Stored option: Screenshot 2024-11-14 at 19 19 19 Synthetic option selected: Screenshot 2024-11-14 at 19 19 27 Disabled option selected: Screenshot 2024-11-14 at 19 19 36 https://github.com/user-attachments/assets/399d0f95-a5dd-4874-bb8c-e95d6ed38465 How to test: 1. Start Es with `yarn es snapshot --license` (we need Enterprise license to see the Synthetic source option) and Kibana with `yarn start` 2. Go to Index templates/Component templates and start creating a template 3. At the Mappings step, go to Advanced options. 4. Verify that selecting a _source field option translates to the correct Es request. 5. In Index templates form, verify that the default _source option depends on the index mode selected in the Logistics step. For LogsDB and Time series index mode, the default should be synthetic mode; otherwise, the stored option. 6. Verify that in Basic license, the synthetic option is not displayed. ### 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 - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + .../helpers/mappings_editor.helpers.tsx | 1 + .../mappings_editor.test.tsx | 102 ++++++++++++- .../configuration_form/configuration_form.tsx | 65 +++++--- .../configuration_form_schema.tsx | 9 +- .../configuration_serialization.test.ts | 144 ++++++++++++++++++ .../source_field_section/constants.ts | 15 ++ .../source_field_section/i18n_texts.ts | 56 +++++++ .../source_field_section/index.ts | 1 + .../source_field_section.tsx | 122 +++++++++++++-- .../mappings_editor/lib/utils.test.ts | 1 + .../mappings_editor/mappings_editor.tsx | 44 +++++- .../mappings_state_context.tsx | 1 + .../components/mappings_editor/reducer.ts | 6 + .../components/mappings_editor/types/state.ts | 4 +- .../components/wizard_steps/step_mappings.tsx | 5 +- .../wizard_steps/step_mappings_container.tsx | 19 ++- .../template_form/template_form.tsx | 5 +- .../application/services/documentation.ts | 6 + .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 22 files changed, 562 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_serialization.test.ts create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/constants.ts create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/i18n_texts.ts diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index c7d714ebfbbb7..a31e1f1641e8b 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -428,6 +428,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D mappingSimilarity: `${ELASTICSEARCH_DOCS}similarity.html`, mappingSourceFields: `${ELASTICSEARCH_DOCS}mapping-source-field.html`, mappingSourceFieldsDisable: `${ELASTICSEARCH_DOCS}mapping-source-field.html#disable-source-field`, + mappingSyntheticSourceFields: `${ELASTICSEARCH_DOCS}mapping-source-field.html#synthetic-source`, mappingStore: `${ELASTICSEARCH_DOCS}mapping-store.html`, mappingSubobjects: `${ELASTICSEARCH_DOCS}subobjects.html`, mappingTermVector: `${ELASTICSEARCH_DOCS}term-vector.html`, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 094fd40ab1e32..349a724043169 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -459,6 +459,7 @@ export type TestSubjects = | 'advancedConfiguration.dynamicMappingsToggle.input' | 'advancedConfiguration.metaField' | 'advancedConfiguration.routingRequiredToggle.input' + | 'sourceValueField' | 'sourceField.includesField' | 'sourceField.excludesField' | 'dynamicTemplatesEditor' diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx index 685aa4963edc4..ee3b3e72e7c19 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx @@ -28,7 +28,24 @@ describe('Mappings editor: core', () => { let onChangeHandler: jest.Mock = jest.fn(); let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); let testBed: MappingsEditorTestBed; - const appDependencies = { plugins: { ml: { mlApi: {} } } }; + let hasEnterpriseLicense = true; + const mockLicenseCheck = jest.fn((type: any) => hasEnterpriseLicense); + const appDependencies = { + plugins: { + ml: { mlApi: {} }, + licensing: { + license$: { + subscribe: jest.fn((callback: any) => { + callback({ + isActive: true, + hasAtLeast: mockLicenseCheck, + }); + return { unsubscribe: jest.fn() }; + }), + }, + }, + }, + }; beforeAll(() => { jest.useFakeTimers({ legacyFakeTimers: true }); @@ -456,6 +473,11 @@ describe('Mappings editor: core', () => { updatedMappings = { ...updatedMappings, dynamic: false, + // The "enabled": true is removed as this is the default in Es + _source: { + includes: defaultMappings._source.includes, + excludes: defaultMappings._source.excludes, + }, }; delete updatedMappings.date_detection; delete updatedMappings.dynamic_date_formats; @@ -463,6 +485,84 @@ describe('Mappings editor: core', () => { expect(data).toEqual(updatedMappings); }); + + describe('props.indexMode sets the correct default value of _source field', () => { + it("defaults to 'stored' with 'standard' index mode prop", async () => { + await act(async () => { + testBed = setup( + { + value: { ...defaultMappings, _source: undefined }, + onChange: onChangeHandler, + indexMode: 'standard', + }, + ctx + ); + }); + testBed.component.update(); + + const { + actions: { selectTab }, + find, + } = testBed; + + await selectTab('advanced'); + + // Check that the stored option is selected + expect(find('sourceValueField').prop('value')).toBe('stored'); + }); + + ['logsdb', 'time_series'].forEach((indexMode) => { + it(`defaults to 'synthetic' with ${indexMode} index mode prop on enterprise license`, async () => { + hasEnterpriseLicense = true; + await act(async () => { + testBed = setup( + { + value: { ...defaultMappings, _source: undefined }, + onChange: onChangeHandler, + indexMode, + }, + ctx + ); + }); + testBed.component.update(); + + const { + actions: { selectTab }, + find, + } = testBed; + + await selectTab('advanced'); + + // Check that the synthetic option is selected + expect(find('sourceValueField').prop('value')).toBe('synthetic'); + }); + + it(`defaults to 'standard' with ${indexMode} index mode prop on basic license`, async () => { + hasEnterpriseLicense = false; + await act(async () => { + testBed = setup( + { + value: { ...defaultMappings, _source: undefined }, + onChange: onChangeHandler, + indexMode, + }, + ctx + ); + }); + testBed.component.update(); + + const { + actions: { selectTab }, + find, + } = testBed; + + await selectTab('advanced'); + + // Check that the stored option is selected + expect(find('sourceValueField').prop('value')).toBe('stored'); + }); + }); + }); }); describe('multi-fields support', () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index e3e32c55aada0..00ce2d02a1baa 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -5,17 +5,21 @@ * 2.0. */ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import { FormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useAppContext } from '../../../../app_context'; import { useForm, Form } from '../../shared_imports'; import { GenericObject, MappingsConfiguration } from '../../types'; import { MapperSizePluginId } from '../../constants'; import { useDispatch } from '../../mappings_state_context'; import { DynamicMappingSection } from './dynamic_mapping_section'; -import { SourceFieldSection } from './source_field_section'; +import { + SourceFieldSection, + STORED_SOURCE_OPTION, + SYNTHETIC_SOURCE_OPTION, + DISABLED_SOURCE_OPTION, +} from './source_field_section'; import { MetaFieldSection } from './meta_field_section'; import { RoutingSection } from './routing_section'; import { MapperSizePluginSection } from './mapper_size_plugin_section'; @@ -28,7 +32,14 @@ interface Props { esNodesPlugins: string[]; } -const formSerializer = (formData: GenericObject, sourceFieldMode?: string) => { +interface SerializedSourceField { + enabled?: boolean; + mode?: string; + includes?: string[]; + excludes?: string[]; +} + +export const formSerializer = (formData: GenericObject) => { const { dynamicMapping, sourceField, metaField, _routing, _size, subobjects } = formData; const dynamic = dynamicMapping?.enabled @@ -37,12 +48,30 @@ const formSerializer = (formData: GenericObject, sourceFieldMode?: string) => { ? 'strict' : dynamicMapping?.enabled; + const _source = + sourceField?.option === SYNTHETIC_SOURCE_OPTION + ? { mode: SYNTHETIC_SOURCE_OPTION } + : sourceField?.option === DISABLED_SOURCE_OPTION + ? { enabled: false } + : sourceField?.option === STORED_SOURCE_OPTION + ? { + mode: 'stored', + includes: sourceField?.includes, + excludes: sourceField?.excludes, + } + : sourceField?.includes || sourceField?.excludes + ? { + includes: sourceField?.includes, + excludes: sourceField?.excludes, + } + : undefined; + const serialized = { dynamic, numeric_detection: dynamicMapping?.numeric_detection, date_detection: dynamicMapping?.date_detection, dynamic_date_formats: dynamicMapping?.dynamic_date_formats, - _source: sourceFieldMode ? { mode: sourceFieldMode } : sourceField, + _source: _source as SerializedSourceField, _meta: metaField, _routing, _size, @@ -52,7 +81,7 @@ const formSerializer = (formData: GenericObject, sourceFieldMode?: string) => { return serialized; }; -const formDeserializer = (formData: GenericObject) => { +export const formDeserializer = (formData: GenericObject) => { const { dynamic, /* eslint-disable @typescript-eslint/naming-convention */ @@ -60,11 +89,7 @@ const formDeserializer = (formData: GenericObject) => { date_detection, dynamic_date_formats, /* eslint-enable @typescript-eslint/naming-convention */ - _source: { enabled, includes, excludes } = {} as { - enabled?: boolean; - includes?: string[]; - excludes?: string[]; - }, + _source: { enabled, mode, includes, excludes } = {} as SerializedSourceField, _meta, _routing, // For the Mapper Size plugin @@ -81,7 +106,14 @@ const formDeserializer = (formData: GenericObject) => { dynamic_date_formats, }, sourceField: { - enabled, + option: + mode === 'stored' + ? STORED_SOURCE_OPTION + : mode === 'synthetic' + ? SYNTHETIC_SOURCE_OPTION + : enabled === false + ? DISABLED_SOURCE_OPTION + : undefined, includes, excludes, }, @@ -99,14 +131,9 @@ export const ConfigurationForm = React.memo(({ value, esNodesPlugins }: Props) = const isMounted = useRef(false); - const serializerCallback = useCallback( - (formData: FormData) => formSerializer(formData, value?._source?.mode), - [value?._source?.mode] - ); - const { form } = useForm({ schema: configurationFormSchema, - serializer: serializerCallback, + serializer: formSerializer, deserializer: formDeserializer, defaultValue: value, id: 'configurationForm', @@ -165,7 +192,7 @@ export const ConfigurationForm = React.memo(({ value, esNodesPlugins }: Props) = - {enableMappingsSourceFieldSection && !value?._source?.mode && ( + {enableMappingsSourceFieldSection && ( <> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx index a9913b9474b36..ff93e717ce090 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx @@ -75,12 +75,9 @@ export const configurationFormSchema: FormSchema = { }, }, sourceField: { - enabled: { - label: i18n.translate('xpack.idxMgmt.mappingsEditor.configuration.sourceFieldLabel', { - defaultMessage: 'Enable _source field', - }), - type: FIELD_TYPES.TOGGLE, - defaultValue: true, + option: { + type: FIELD_TYPES.SUPER_SELECT, + defaultValue: 'stored', }, includes: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.configuration.includeSourceFieldsLabel', { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_serialization.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_serialization.test.ts new file mode 100644 index 0000000000000..5bf4ad3b9ee57 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_serialization.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { formSerializer, formDeserializer } from './configuration_form'; +import { + STORED_SOURCE_OPTION, + SYNTHETIC_SOURCE_OPTION, + DISABLED_SOURCE_OPTION, +} from './source_field_section'; + +describe('Template serialization', () => { + describe('serialization of _source parameter', () => { + describe('deserializeTemplate()', () => { + test(`correctly deserializes 'stored' mode`, () => { + expect( + formDeserializer({ + _source: { + mode: 'stored', + includes: ['hello'], + excludes: ['world'], + }, + }) + ).toHaveProperty('sourceField', { + option: STORED_SOURCE_OPTION, + includes: ['hello'], + excludes: ['world'], + }); + }); + + test(`correctly deserializes 'enabled' property set to true`, () => { + expect( + formDeserializer({ + _source: { + enabled: true, + includes: ['hello'], + excludes: ['world'], + }, + }) + ).toHaveProperty('sourceField', { + includes: ['hello'], + excludes: ['world'], + }); + }); + + test(`correctly deserializes 'enabled' property set to false`, () => { + expect( + formDeserializer({ + _source: { + enabled: false, + }, + }) + ).toHaveProperty('sourceField', { + option: DISABLED_SOURCE_OPTION, + }); + }); + + test(`correctly deserializes 'synthetic' mode`, () => { + expect( + formDeserializer({ + _source: { + mode: 'synthetic', + }, + }) + ).toHaveProperty('sourceField', { + option: SYNTHETIC_SOURCE_OPTION, + }); + }); + + test(`correctly deserializes undefined mode and enabled properties with includes or excludes fields`, () => { + expect( + formDeserializer({ + _source: { + includes: ['hello'], + excludes: ['world'], + }, + }) + ).toHaveProperty('sourceField', { + includes: ['hello'], + excludes: ['world'], + }); + }); + }); + + describe('serializeTemplate()', () => { + test(`correctly serializes 'stored' option`, () => { + expect( + formSerializer({ + sourceField: { + option: STORED_SOURCE_OPTION, + includes: ['hello'], + excludes: ['world'], + }, + }) + ).toHaveProperty('_source', { + mode: 'stored', + includes: ['hello'], + excludes: ['world'], + }); + }); + + test(`correctly serializes 'disabled' option`, () => { + expect( + formSerializer({ + sourceField: { + option: DISABLED_SOURCE_OPTION, + }, + }) + ).toHaveProperty('_source', { + enabled: false, + }); + }); + + test(`correctly serializes 'synthetic' option`, () => { + expect( + formSerializer({ + sourceField: { + option: SYNTHETIC_SOURCE_OPTION, + }, + }) + ).toHaveProperty('_source', { + mode: 'synthetic', + }); + }); + + test(`correctly serializes undefined option with includes or excludes fields`, () => { + expect( + formSerializer({ + sourceField: { + includes: ['hello'], + excludes: ['world'], + }, + }) + ).toHaveProperty('_source', { + includes: ['hello'], + excludes: ['world'], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/constants.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/constants.ts new file mode 100644 index 0000000000000..9e4390846fa81 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/constants.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. + */ + +export const STORED_SOURCE_OPTION = 'stored'; +export const DISABLED_SOURCE_OPTION = 'disabled'; +export const SYNTHETIC_SOURCE_OPTION = 'synthetic'; + +export type SourceOptionKey = + | typeof STORED_SOURCE_OPTION + | typeof DISABLED_SOURCE_OPTION + | typeof SYNTHETIC_SOURCE_OPTION; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/i18n_texts.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/i18n_texts.ts new file mode 100644 index 0000000000000..447c45b7b099d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/i18n_texts.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 { + STORED_SOURCE_OPTION, + DISABLED_SOURCE_OPTION, + SYNTHETIC_SOURCE_OPTION, + SourceOptionKey, +} from './constants'; + +export const sourceOptionLabels: Record = { + [STORED_SOURCE_OPTION]: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.configuration.storedSourceFieldsLabel', + { + defaultMessage: 'Stored _source', + } + ), + [DISABLED_SOURCE_OPTION]: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.configuration.disabledSourceFieldsLabel', + { + defaultMessage: 'Disabled _source', + } + ), + [SYNTHETIC_SOURCE_OPTION]: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.configuration.syntheticSourceFieldsLabel', + { + defaultMessage: 'Synthetic _source', + } + ), +}; + +export const sourceOptionDescriptions: Record = { + [STORED_SOURCE_OPTION]: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.configuration.storedSourceFieldsDescription', + { + defaultMessage: 'Stores content in _source field for future retrieval', + } + ), + [DISABLED_SOURCE_OPTION]: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.configuration.disabledSourceFieldsDescription', + { + defaultMessage: 'Strongly discouraged, will impact downstream functionality', + } + ), + [SYNTHETIC_SOURCE_OPTION]: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.configuration.syntheticSourceFieldsDescription', + { + defaultMessage: 'Reconstructs source content to save on disk usage', + } + ), +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/index.ts index df921a036c909..cb5f2afef6d0b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/index.ts @@ -6,3 +6,4 @@ */ export { SourceFieldSection } from './source_field_section'; +export * from './constants'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx index 236dfe98119ca..2e8f9fb88f87d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx @@ -5,18 +5,61 @@ * 2.0. */ -import React from 'react'; +import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiLink, EuiSpacer, EuiComboBox, EuiFormRow, EuiCallOut } from '@elastic/eui'; +import { EuiLink, EuiSpacer, EuiComboBox, EuiFormRow, EuiCallOut, EuiText } from '@elastic/eui'; +import { useMappingsState } from '../../../mappings_state_context'; import { documentationService } from '../../../../../services/documentation'; -import { UseField, FormDataProvider, FormRow, ToggleField } from '../../../shared_imports'; +import { UseField, FormDataProvider, FormRow, SuperSelectField } from '../../../shared_imports'; import { ComboBoxOption } from '../../../types'; +import { sourceOptionLabels, sourceOptionDescriptions } from './i18n_texts'; +import { + STORED_SOURCE_OPTION, + DISABLED_SOURCE_OPTION, + SYNTHETIC_SOURCE_OPTION, + SourceOptionKey, +} from './constants'; export const SourceFieldSection = () => { - const renderWarning = () => ( + const state = useMappingsState(); + + const renderOptionDropdownDisplay = (option: SourceOptionKey) => ( + + {sourceOptionLabels[option]} + +

{sourceOptionDescriptions[option]}

+
+
+ ); + + const sourceValueOptions = [ + { + value: STORED_SOURCE_OPTION, + inputDisplay: sourceOptionLabels[STORED_SOURCE_OPTION], + dropdownDisplay: renderOptionDropdownDisplay(STORED_SOURCE_OPTION), + 'data-test-subj': 'storedSourceFieldOption', + }, + ]; + + if (state.hasEnterpriseLicense) { + sourceValueOptions.push({ + value: SYNTHETIC_SOURCE_OPTION, + inputDisplay: sourceOptionLabels[SYNTHETIC_SOURCE_OPTION], + dropdownDisplay: renderOptionDropdownDisplay(SYNTHETIC_SOURCE_OPTION), + 'data-test-subj': 'syntheticSourceFieldOption', + }); + } + sourceValueOptions.push({ + value: DISABLED_SOURCE_OPTION, + inputDisplay: sourceOptionLabels[DISABLED_SOURCE_OPTION], + dropdownDisplay: renderOptionDropdownDisplay(DISABLED_SOURCE_OPTION), + 'data-test-subj': 'disabledSourceFieldOption', + }); + + const renderDisableWarning = () => ( {

@@ -45,13 +88,13 @@ export const SourceFieldSection = () => {

@@ -70,6 +113,44 @@ export const SourceFieldSection = () => { ); + const renderSyntheticWarning = () => ( + + {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2.sourceText', + { + defaultMessage: '_source', + } + )} + + ), + learnMoreLink: ( + + {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2.sourceText', + { + defaultMessage: 'Learn more.', + } + )} + + ), + }} + /> + } + iconType="warning" + color="warning" + /> + ); + const renderFormFields = () => (

@@ -155,21 +236,34 @@ export const SourceFieldSection = () => { }} /> - + } > - + {(formData) => { - const { - sourceField: { enabled }, - } = formData; + const { sourceField } = formData; - if (enabled === undefined) { + if (sourceField?.option === undefined) { return null; } - return enabled ? renderFormFields() : renderWarning(); + return sourceField?.option === STORED_SOURCE_OPTION + ? renderFormFields() + : sourceField?.option === DISABLED_SOURCE_OPTION + ? renderDisableWarning() + : renderSyntheticWarning(); }} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts index 58b40293f64f2..872c62bc6f7a7 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts @@ -421,6 +421,7 @@ describe('utils', () => { selectedDataTypes: ['Boolean'], }, inferenceToModelIdMap: {}, + hasEnterpriseLicense: true, mappingViewFields: { byId: {}, rootLevelFields: [], aliases: {}, maxNestedDepth: 0 }, }; test('returns list of matching fields with search term', () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index e1f5306899db3..cc87c3cd614e3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -9,6 +9,9 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; +import { ILicense } from '@kbn/licensing-plugin/common/types'; +import { useAppContext } from '../../app_context'; +import { IndexMode } from '../../../../common/types/data_streams'; import { DocumentFields, RuntimeFieldsList, @@ -32,6 +35,7 @@ import { DocLinksStart } from './shared_imports'; import { DocumentFieldsHeader } from './components/document_fields/document_fields_header'; import { SearchResult } from './components/document_fields/search_fields'; import { parseMappings } from '../../shared/parse_mappings'; +import { LOGSDB_INDEX_MODE, TIME_SERIES_MODE } from '../../../../common/constants'; type TabName = 'fields' | 'runtimeFields' | 'advanced' | 'templates'; @@ -52,10 +56,14 @@ export interface Props { docLinks: DocLinksStart; /** List of plugins installed in the cluster nodes */ esNodesPlugins: string[]; + indexMode?: IndexMode; } export const MappingsEditor = React.memo( - ({ onChange, value, docLinks, indexSettings, esNodesPlugins }: Props) => { + ({ onChange, value, docLinks, indexSettings, esNodesPlugins, indexMode }: Props) => { + const { + plugins: { licensing }, + } = useAppContext(); const { parsedDefaultValue, multipleMappingsDeclared } = useMemo( () => parseMappings(value), [value] @@ -120,6 +128,40 @@ export const MappingsEditor = React.memo( [dispatch] ); + const [isLicenseCheckComplete, setIsLicenseCheckComplete] = useState(false); + useEffect(() => { + const subscription = licensing?.license$.subscribe((license: ILicense) => { + dispatch({ + type: 'hasEnterpriseLicense.update', + value: license.isActive && license.hasAtLeast('enterprise'), + }); + setIsLicenseCheckComplete(true); + }); + + return () => subscription?.unsubscribe(); + }, [dispatch, licensing]); + + useEffect(() => { + if ( + isLicenseCheckComplete && + !state.configuration.defaultValue._source && + (indexMode === LOGSDB_INDEX_MODE || indexMode === TIME_SERIES_MODE) + ) { + if (state.hasEnterpriseLicense) { + dispatch({ + type: 'configuration.save', + value: { ...state.configuration.defaultValue, _source: { mode: 'synthetic' } } as any, + }); + } + } + }, [ + indexMode, + dispatch, + state.configuration, + state.hasEnterpriseLicense, + isLicenseCheckComplete, + ]); + const tabToContentMap = { fields: ( = ({ childr selectedDataTypes: [], }, inferenceToModelIdMap: {}, + hasEnterpriseLicense: false, mappingViewFields: { byId: {}, rootLevelFields: [], aliases: {}, maxNestedDepth: 0 }, }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index 626ee0e839a8a..ecb9648c34d00 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -629,5 +629,11 @@ export const reducer = (state: State, action: Action): State => { inferenceToModelIdMap: action.value.inferenceToModelIdMap, }; } + case 'hasEnterpriseLicense.update': { + return { + ...state, + hasEnterpriseLicense: action.value, + }; + } } }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts index f40fe420eb3be..43b3a7dde3b16 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts @@ -108,6 +108,7 @@ export interface State { }; templates: TemplatesFormState; inferenceToModelIdMap?: InferenceToModelIdMap; + hasEnterpriseLicense: boolean; mappingViewFields: NormalizedFields; // state of the incoming index mappings, separate from the editor state above } @@ -140,6 +141,7 @@ export type Action = | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } | { type: 'search:update'; value: string } | { type: 'validity:update'; value: boolean } - | { type: 'filter:update'; value: { selectedOptions: EuiSelectableOption[] } }; + | { type: 'filter:update'; value: { selectedOptions: EuiSelectableOption[] } } + | { type: 'hasEnterpriseLicense.update'; value: boolean }; export type Dispatch = (action: Action) => void; diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx index 62685a05f7ff9..a239971c1bf82 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx @@ -16,6 +16,7 @@ import { EuiText, } from '@elastic/eui'; +import { IndexMode } from '../../../../../../common/types/data_streams'; import { Forms } from '../../../../../shared_imports'; import { useAppContext } from '../../../../app_context'; import { @@ -33,10 +34,11 @@ interface Props { esNodesPlugins: string[]; defaultValue?: { [key: string]: any }; indexSettings?: IndexSettings; + indexMode?: IndexMode; } export const StepMappings: React.FunctionComponent = React.memo( - ({ defaultValue = {}, onChange, indexSettings, esDocsBase, esNodesPlugins }) => { + ({ defaultValue = {}, onChange, indexSettings, esDocsBase, esNodesPlugins, indexMode }) => { const [mappings, setMappings] = useState(defaultValue); const { docLinks } = useAppContext(); @@ -115,6 +117,7 @@ export const StepMappings: React.FunctionComponent = React.memo( indexSettings={indexSettings} docLinks={docLinks} esNodesPlugins={esNodesPlugins} + indexMode={indexMode} /> diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx index 50b763ce0d06a..1b8a6bac2a4d8 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import { WizardContent } from '../../../template_form/template_form'; +import { TemplateDeserialized } from '../../../../../../common'; import { Forms } from '../../../../../shared_imports'; import { useLoadNodesPlugins } from '../../../../services'; import { CommonWizardSteps } from './types'; @@ -14,15 +16,29 @@ import { StepMappings } from './step_mappings'; interface Props { esDocsBase: string; + getTemplateData?: (wizardContent: WizardContent) => TemplateDeserialized; } -export const StepMappingsContainer: React.FunctionComponent = ({ esDocsBase }) => { +export const StepMappingsContainer: React.FunctionComponent = ({ + esDocsBase, + getTemplateData, +}) => { const { defaultValue, updateContent, getSingleContentData } = Forms.useContent< CommonWizardSteps, 'mappings' >('mappings'); const { data: esNodesPlugins } = useLoadNodesPlugins(); + const { getData } = Forms.useMultiContentContext(); + + let indexMode; + if (getTemplateData) { + const wizardContent = getData(); + // Build the current template object, providing the wizard content data + const template = getTemplateData(wizardContent); + indexMode = template?.indexMode; + } + return ( = ({ esDocsBa indexSettings={getSingleContentData('settings')} esDocsBase={esDocsBase} esNodesPlugins={esNodesPlugins ?? []} + indexMode={indexMode} /> ); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 53b53a6ebdeee..1f3d2a22874d3 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -349,7 +349,10 @@ export const TemplateForm = ({ - + diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index 58aba69351883..62b7defd78db1 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -55,6 +55,7 @@ class DocumentationService { private mappingSimilarity: string = ''; private mappingSourceFields: string = ''; private mappingSourceFieldsDisable: string = ''; + private mappingSyntheticSourceFields: string = ''; private mappingStore: string = ''; private mappingSubobjects: string = ''; private mappingTermVector: string = ''; @@ -115,6 +116,7 @@ class DocumentationService { this.mappingSimilarity = links.elasticsearch.mappingSimilarity; this.mappingSourceFields = links.elasticsearch.mappingSourceFields; this.mappingSourceFieldsDisable = links.elasticsearch.mappingSourceFieldsDisable; + this.mappingSyntheticSourceFields = links.elasticsearch.mappingSyntheticSourceFields; this.mappingStore = links.elasticsearch.mappingStore; this.mappingSubobjects = links.elasticsearch.mappingSubobjects; this.mappingTermVector = links.elasticsearch.mappingTermVector; @@ -215,6 +217,10 @@ class DocumentationService { return this.mappingSourceFieldsDisable; } + public getMappingSyntheticSourceFieldLink() { + return this.mappingSyntheticSourceFields; + } + public getNullValueLink() { return this.mappingNullValue; } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index cb51a020eeaac..6d1ba23a5f6c4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -22923,7 +22923,6 @@ "xpack.idxMgmt.mappingsEditor.configuration.numericFieldLabel": "Mapper les chaînes numériques en tant que nombres", "xpack.idxMgmt.mappingsEditor.configuration.routingLabel": "Demander une valeur _routing pour les opérations CRUD", "xpack.idxMgmt.mappingsEditor.configuration.sizeLabel": "Indexer la taille du champ _source en octets", - "xpack.idxMgmt.mappingsEditor.configuration.sourceFieldLabel": "Activer le champ _source", "xpack.idxMgmt.mappingsEditor.configuration.sourceFieldPathComboBoxHelpText": "Accepte un chemin d'accès au champ, y compris les caractères génériques.", "xpack.idxMgmt.mappingsEditor.configuration.subobjectsLabel": "Autoriser les objets à contenir d'autres sous-objets", "xpack.idxMgmt.mappingsEditor.configuration.throwErrorsForUnmappedFieldsLabel": "Lever une exception lorsqu'un document contient un champ non mappé", @@ -23066,7 +23065,6 @@ "xpack.idxMgmt.mappingsEditor.dimsFieldLabel": "Dimensions", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription1": "La désactivation de {source} réduit la surcharge de stockage dans l'index, mais cela a un coût. Cette action désactive également des fonctionnalités essentielles, comme la capacité à réindexer ou à déboguer les requêtes en affichant le document d'origine.", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription1.sourceText": "_source", - "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2": "Découvrez-en plus sur les alternatives à la désactivation du champ {source}.", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2.sourceText": "_source", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutTitle": "Faites preuve de prudence lorsque vous désactivez le champ _source", "xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsAriaLabel": "Rechercher dans les champs mappés", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b5a0c4a1481df..04bc03184b777 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22895,7 +22895,6 @@ "xpack.idxMgmt.mappingsEditor.configuration.numericFieldLabel": "数字の文字列の数値としてのマッピング", "xpack.idxMgmt.mappingsEditor.configuration.routingLabel": "CRUD操作のためのRequire _routing値", "xpack.idxMgmt.mappingsEditor.configuration.sizeLabel": "_sourceフィールドサイズ(バイト)にインデックスを作成", - "xpack.idxMgmt.mappingsEditor.configuration.sourceFieldLabel": "_sourceフィールドの有効化", "xpack.idxMgmt.mappingsEditor.configuration.sourceFieldPathComboBoxHelpText": "ワイルドカードを含め、フィールドへのパスを受け入れます。", "xpack.idxMgmt.mappingsEditor.configuration.subobjectsLabel": "オブジェクトがさらにサブオブジェクトを保持することを許可", "xpack.idxMgmt.mappingsEditor.configuration.throwErrorsForUnmappedFieldsLabel": "ドキュメントがマッピングされていないフィールドを含む場合に例外を選択する", @@ -23038,7 +23037,6 @@ "xpack.idxMgmt.mappingsEditor.dimsFieldLabel": "次元", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription1": "{source}を無効にすることで、インデックス内のストレージオーバーヘッドが削減されますが、これにはコストがかかります。これはまた、元のドキュメントを表示して、再インデックスやクエリーのデバッグといった重要な機能を無効にします。", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription1.sourceText": "_source", - "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2": "{source}フィールドを無効にするための代替方法の詳細", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2.sourceText": "_source", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutTitle": "_source fieldを無効にする際は慎重に行う", "xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsAriaLabel": "マッピングされたフィールドの検索", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8c21f00ca8228..1d48e398dfd0c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22503,7 +22503,6 @@ "xpack.idxMgmt.mappingsEditor.configuration.numericFieldLabel": "将数值字符串映射为数字", "xpack.idxMgmt.mappingsEditor.configuration.routingLabel": "CRUD 操作需要 _routing 值", "xpack.idxMgmt.mappingsEditor.configuration.sizeLabel": "索引 _source 字段大小(字节)", - "xpack.idxMgmt.mappingsEditor.configuration.sourceFieldLabel": "启用 _source 字段", "xpack.idxMgmt.mappingsEditor.configuration.sourceFieldPathComboBoxHelpText": "接受字段的路径,包括通配符。", "xpack.idxMgmt.mappingsEditor.configuration.subobjectsLabel": "允许对象存放更多子对象", "xpack.idxMgmt.mappingsEditor.configuration.throwErrorsForUnmappedFieldsLabel": "文档包含未映射字段时引发异常", @@ -22644,7 +22643,6 @@ "xpack.idxMgmt.mappingsEditor.dimsFieldLabel": "维度数", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription1": "禁用 {source} 可降低索引内的存储开销,这有一定的代价。其还禁用重要的功能,如通过查看原始文档来重新索引或调试查询的功能。", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription1.sourceText": "_source", - "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2": "详细了解禁用 {source} 字段的备选方式。", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutDescription2.sourceText": "_source", "xpack.idxMgmt.mappingsEditor.disabledSourceFieldCallOutTitle": "禁用 _source 字段时要十分谨慎", "xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsAriaLabel": "搜索映射的字段", From 74f4a9b03aa3c7e5b34de8a552ae2e8c35be1739 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Nov 2024 16:56:11 +0000 Subject: [PATCH 36/61] [Ownership] Assign test files to response ops team (#199797) ## Summary Assign test files to response ops team Contributes to: #192979 --- .github/CODEOWNERS | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 268f1d5148d43..4d31e2808be33 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1736,12 +1736,33 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib #CC# /x-pack/plugins/security/ @elastic/kibana-security # Response Ops team -/x-pack/test/examples/triggers_actions_ui_examples @elastic/response-ops +/x-pack/test/plugin_api_perf @elastic/response-ops # Assigned per https://github.com/elastic/kibana/blob/assign-response-ops/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.jsonc#L4 +/x-pack/test/functional/page_objects/maintenance_windows_page.ts @elastic/response-ops +/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/index.ts @elastic/response-ops +/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts @elastic/response-ops +/x-pack/test/functional_with_es_ssl/config.base.ts @elastic/response-ops # Assigned per https://github.com/elastic/kibana/pull/197070 +/x-pack/test/functional_with_es_ssl/lib/alert_api_actions.ts @elastic/response-ops +/x-pack/test/functional_with_es_ssl/lib/get_test_data.ts @elastic/response-ops +/x-pack/test/functional_with_es_ssl/page_objects/index.ts @elastic/response-ops # Assigned per git blame +/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @elastic/response-ops +/x-pack/test/functional_with_es_ssl/page_objects/rule_details.ts @elastic/response-ops +/x-pack/test/functional_with_es_ssl/lib/object_remover.ts @elastic/response-ops +/x-pack/test/stack_functional_integration/apps/alerts @elastic/response-ops +/x-pack/test/functional/services/actions @elastic/response-ops +/x-pack/test/api_integration_basic/apis/security_solution/index.ts @elastic/response-ops +/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts @elastic/response-ops +/x-pack/test/upgrade/services/rules_upgrade_services.ts @elastic/response-ops +/x-pack/test/upgrade/apps/rules @elastic/response-ops +/x-pack/test/examples/triggers_actions_ui_examples @elastic/response-ops # Assigned per https://github.com/elastic/kibana/blob/main/x-pack/examples/triggers_actions_ui_example/kibana.jsonc#L4 +/x-pack/test/functional/services/rules @elastic/response-ops +/x-pack/test/plugin_api_integration/plugins/sample_task_plugin @elastic/response-ops +/x-pack/test/functional/fixtures/kbn_archiver/cases @elastic/response-ops +/x-pack/test/functional/es_archives/cases @elastic/response-ops +/x-pack/test/functional_with_es_ssl/plugins/alerts @elastic/response-ops /x-pack/test/functional_with_es_ssl/plugins/cases @elastic/response-ops /x-pack/test/screenshot_creation/apps/response_ops_docs @elastic/response-ops /x-pack/test/rule_registry @elastic/response-ops @elastic/obs-ux-management-team /x-pack/test/accessibility/apps/group3/rules_connectors.ts @elastic/response-ops -/x-pack/test/functional/es_archives/cases/default @elastic/response-ops /x-pack/test_serverless/functional/page_objects/svl_triggers_actions_ui_page.ts @elastic/response-ops /x-pack/test_serverless/functional/page_objects/svl_rule_details_ui_page.ts @elastic/response-ops /x-pack/test_serverless/functional/page_objects/svl_oblt_overview_page.ts @elastic/response-ops From c1af3d1d833b60f3667554ac2721c768a91b493e Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Nov 2024 17:02:05 +0000 Subject: [PATCH 37/61] [Ownership] Assign test files to logstash team (#199783) ## Summary Assign test files to logstash team Contributes to: https://github.com/elastic/kibana/issues/192979 --------- Co-authored-by: Robert Oskamp Co-authored-by: Elastic Machine --- .github/CODEOWNERS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4d31e2808be33..793147eb9a4f3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2290,8 +2290,10 @@ x-pack/plugins/security_solution/server/lib/security_integrations @elastic/secur /x-pack/plugins/security_solution_serverless/**/*.scss @elastic/security-design # Logstash -/x-pack/test/api_integration/apis/logstash @elastic/logstash +/x-pack/test/functional/services/pipeline_* @elastic/logstash +/x-pack/test/functional/page_objects/logstash_page.ts @elastic/logstash /x-pack/test/functional/apps/logstash @elastic/logstash +/x-pack/test/api_integration/apis/logstash @elastic/logstash #CC# /x-pack/plugins/logstash/ @elastic/logstash # EUI team From 7699806ae5c996eb6e0c45641ff458f290eb4450 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Nov 2024 17:07:32 +0000 Subject: [PATCH 38/61] [Ownership] Assign test files to Core Team (#199771) ## Summary Assign test files to Core Team Contributes to: https://github.com/elastic/kibana/issues/192979 --------- Co-authored-by: Robert Oskamp Co-authored-by: Jean-Louis Leysens --- .github/CODEOWNERS | 62 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 793147eb9a4f3..6898e69a44eeb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1621,10 +1621,66 @@ x-pack/test/**/deployment_agnostic/ @elastic/appex-qa #temporarily to monitor te # Core /test/api_integration/apis/general/*.js @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/199795/files/894a8ede3f9d0398c5af56bf5a82654a9bc0610b#r1846691639 /x-pack/test/plugin_api_integration/plugins/feature_usage_test @elastic/kibana-core -/test/plugin_functional/plugins/rendering_plugin @elastic/kibana-core -/test/plugin_functional/plugins/session_notifications @elastic/kibana-core +/x-pack/test/functional/page_objects/navigational_search.ts @elastic/kibana-core +/x-pack/test/stack_functional_integration/apps/savedobjects_upgrade_testing @elastic/kibana-core +/x-pack/test/functional/page_objects/status_page.ts @elastic/kibana-core +/x-pack/test/functional/page_objects/share_saved_objects_to_space_page.ts @elastic/kibana-core +/x-pack/test/functional/page_objects/banners_page.ts @elastic/kibana-core +/x-pack/test/common/lib/test_data_loader.ts @elastic/kibana-core +/x-pack/test/api_integration/services/usage_api.ts @elastic/kibana-core +/x-pack/test/api_integration/apis/kibana @elastic/kibana-core +/test/api_integration/fixtures/import.ndjson @elastic/kibana-core +/x-pack/test/plugin_api_integration @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/146704 +/x-pack/test/localization/ @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/146704 +/test/ui_capabilities/newsfeed_err @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/66562 +/test/server_integration/services/types.d.ts @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/81140 +/test/server_integration/http @elastic/kibana-core +/test/scripts/run_multiple_kibana_nodes.sh @elastic/kibana-core +/test/functional/services/usage_collection.ts @elastic/kibana-core +/test/api_integration/fixtures/import_managed.ndjson @elastic/kibana-core +/test/functional/services/apps_menu.ts @elastic/kibana-core +/x-pack/test/functional/apps/status_page @elastic/kibana-core +/x-pack/test/cloud_integration @elastic/kibana-core /x-pack/test/cloud_integration/plugins/saml_provider @elastic/kibana-core -/x-pack/test/functional_embedded/plugins/iframe_embedded @elastic/kibana-core +/test/server_integration @elastic/kibana-core +/x-pack/test/functional_cors @elastic/kibana-core +/x-pack/test/stack_functional_integration/apps/telemetry @elastic/kibana-core +/test/plugin_functional/plugins/core* @elastic/kibana-core +/test/plugin_functional/plugins/telemetry @elastic/kibana-core +/test/plugin_functional/plugins/session_notifications @elastic/kibana-core +/test/plugin_functional/plugins/kbn_top_nav/ @elastic/kibana-core +/test/plugin_functional/plugins/app_link_test @elastic/kibana-core +/test/plugin_functional/plugins/saved_object* @elastic/kibana-core +/test/plugin_functional/plugins/rendering_plugin @elastic/kibana-core +/test/plugin_functional/test_suites/application_links @elastic/kibana-core +/test/plugin_functional/test_suites/telemetry @elastic/kibana-core +/test/plugin_functional/test_suites/usage_collection @elastic/kibana-core +/test/plugin_functional/test_suites/saved_objects* @elastic/kibana-core +/test/plugin_functional/test_suites/core* @elastic/kibana-core +/test/interpreter_functional/plugins/kbn_tp_run_pipeline @elastic/kibana-core +/x-pack/test/functional/fixtures/kbn_archiver/saved_objects_management @elastic/kibana-core +/x-pack/test/functional_embedded @elastic/kibana-core +/test/node_roles_functional @elastic/kibana-core +/test/functional/page_objects/newsfeed_page.ts @elastic/kibana-core # assigned per https://github.com/elastic/kibana/pull/160210 +/test/functional/page_objects/home_page.ts @elastic/kibana-core +/test/functional/fixtures/es_archiver/deprecations_service @elastic/kibana-core +/test/health_gateway @elastic/kibana-core +/test/api_integration/apis/saved_objects* @elastic/kibana-core +/test/health_gateway @elastic/kibana-core +/test/node_roles_functional @elastic/kibana-core +/test/functional/firefox/home.config.ts @elastic/kibana-core +/test/functional/apps/status_page/*.ts @elastic/kibana-core +/test/functional/apps/bundles @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/64367 +/test/examples/hello_world @elastic/kibana-core +/test/examples/routing/index.ts @elastic/kibana-core # Assigned per https://github.com/elastic/kibana/pull/69581 +/test/common/plugins/newsfeed @elastic/kibana-core +/test/common/configure_http2.ts @elastic/kibana-core +/test/api_integration/apis/ui_counters @elastic/kibana-core +/test/api_integration/apis/telemetry @elastic/kibana-core +/test/api_integration/apis/status @elastic/kibana-core +/test/api_integration/apis/stats @elastic/kibana-core # Assigned per: https://github.com/elastic/kibana/pull/20577 +/test/api_integration/apis/saved_objects* @elastic/kibana-core +/test/api_integration/apis/core/*.ts @elastic/kibana-core /x-pack/test/functional/apps/saved_objects_management @elastic/kibana-core /x-pack/test/usage_collection @elastic/kibana-core /x-pack/test/licensing_plugin @elastic/kibana-core From 3e9d77a091b56016d13c1eee30e697dd3066029c Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 20 Nov 2024 04:19:44 +1100 Subject: [PATCH 39/61] Authorized route migration for routes owned by security-solution (#198382) ### Authz API migration for authorized routes This PR migrates `access:` tags used in route definitions to new security configuration. Please refer to the documentation for more information: [Authorization API](https://docs.elastic.dev/kibana-dev-docs/key-concepts/security-api-authorization) ### **Before migration:** Access control tags were defined in the `options` object of the route: ```ts router.get({ path: '/api/path', options: { tags: ['access:', 'access:'], }, ... }, handler); ``` ### **After migration:** Tags have been replaced with the more robust `security.authz.requiredPrivileges` field under `security`: ```ts router.get({ path: '/api/path', security: { authz: { requiredPrivileges: ['', ''], }, }, ... }, handler); ``` ### What to do next? 1. Review the changes in this PR. 2. You might need to update your tests to reflect the new security configuration: - If you have tests that rely on checking `access` tags. - If you have snapshot tests that include the route definition. - If you have FTR tests that rely on checking unauthorized error message. The error message changed to also include missing privileges. ## Any questions? If you have any questions or need help with API authorization, please reach out to the `@elastic/kibana-security` team. Co-authored-by: Elastic Machine --- .../server/lib/dashboards/routes/get_dashboards_by_tags.ts | 6 ++++-- .../routes/privileges/read_privileges_route.ts | 6 ++++-- .../telemetry/telemetry_detection_rules_preview_route.ts | 6 ++++-- .../routes/users/suggest_user_profiles_route.ts | 6 ++++-- .../server/lib/exceptions/api/manage_exceptions/route.ts | 6 ++++-- .../security_solution/server/lib/tags/routes/create_tag.ts | 6 ++++-- .../server/lib/tags/routes/get_tags_by_name.ts | 6 ++++-- 7 files changed, 28 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts b/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts index dda4a6af5d221..28e823874b529 100644 --- a/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts +++ b/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts @@ -21,8 +21,10 @@ export const getDashboardsByTagsRoute = (router: SecuritySolutionPluginRouter, l .post({ path: INTERNAL_DASHBOARDS_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts index 314d2c273b04a..22c031d5d5eb5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts @@ -22,8 +22,10 @@ export const readPrivilegesRoute = ( .get({ path: DETECTION_ENGINE_PRIVILEGES_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/telemetry_detection_rules_preview_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/telemetry_detection_rules_preview_route.ts index 271e6e7d27749..8013b2af9742b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/telemetry_detection_rules_preview_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/telemetry_detection_rules_preview_route.ts @@ -26,8 +26,10 @@ export const telemetryDetectionRulesPreviewRoute = ( .get({ path: SECURITY_TELEMETRY_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts index 1b1aeada05660..2b8f65af12ca5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts @@ -23,8 +23,10 @@ export const suggestUserProfilesRoute = ( .get({ path: DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/exceptions/api/manage_exceptions/route.ts b/x-pack/plugins/security_solution/server/lib/exceptions/api/manage_exceptions/route.ts index 01a04a284b16a..5b2a3a70be1a2 100644 --- a/x-pack/plugins/security_solution/server/lib/exceptions/api/manage_exceptions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/exceptions/api/manage_exceptions/route.ts @@ -23,8 +23,10 @@ export const createSharedExceptionListRoute = (router: SecuritySolutionPluginRou .post({ path: SHARED_EXCEPTION_LIST_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/tags/routes/create_tag.ts b/x-pack/plugins/security_solution/server/lib/tags/routes/create_tag.ts index 1604b4374b984..8b76d6b380893 100644 --- a/x-pack/plugins/security_solution/server/lib/tags/routes/create_tag.ts +++ b/x-pack/plugins/security_solution/server/lib/tags/routes/create_tag.ts @@ -21,8 +21,10 @@ export const createTagRoute = (router: SecuritySolutionPluginRouter, logger: Log .put({ path: INTERNAL_TAGS_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/tags/routes/get_tags_by_name.ts b/x-pack/plugins/security_solution/server/lib/tags/routes/get_tags_by_name.ts index 75ae24d0eacd5..dc5a9da70c71f 100644 --- a/x-pack/plugins/security_solution/server/lib/tags/routes/get_tags_by_name.ts +++ b/x-pack/plugins/security_solution/server/lib/tags/routes/get_tags_by_name.ts @@ -21,8 +21,10 @@ export const getTagsByNameRoute = (router: SecuritySolutionPluginRouter, logger: .get({ path: INTERNAL_TAGS_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( From 5268fc5d77920dd923f4b4b80ab234a1146a6164 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 19 Nov 2024 13:14:17 -0500 Subject: [PATCH 40/61] Dependency ownership for QA team (#200589) ## Summary This updates our `renovate.json` configuration to mark the QA team as owners of their set of dependencies. --- renovate.json | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index e7ab31d95f756..fde56431cb5b8 100644 --- a/renovate.json +++ b/renovate.json @@ -100,7 +100,7 @@ "enabled": true }, { - "groupName": "@elastic/appex-ai-infra", + "groupName": "@elastic/appex-ai-infra dependencies", "matchDepNames": [ "@aws-crypto/sha256-js", "@aws-crypto/util", @@ -133,6 +133,45 @@ ], "enabled": true }, + { + "groupName": "@elastic/appex-qa dependencies", + "matchDepNames": [ + "cheerio", + "@istanbuljs/nyc-config-typescript", + "@istanbuljs/schema", + "@types/enzyme", + "@types/faker", + "@types/pixelmatch", + "@types/pngjs", + "@types/supertest", + "@wojtekmaj/enzyme-adapter-react-17", + "babel-plugin-istanbul", + "enzyme", + "enzyme-to-json", + "faker", + "nyc", + "oboe", + "pixelmatch", + "playwright", + "pngjs", + "sharp", + "superagent", + "supertest", + "xmlbuilder" + ], + "reviewers": [ + "team:appex-qa" + ], + "matchBaseBranches": [ + "main" + ], + "labels": [ + "Team:QA", + "release_note:skip", + "backport:all-open" + ], + "enabled": true + }, { "groupName": "@elastic/charts", "matchDepNames": [ From ce17d458456c9739750c60bc661eeeca968f24cb Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:17:49 +0000 Subject: [PATCH 41/61] skip flaky suite (#193554) --- .../components/actions_log_users_filter.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx index 535c0114426dd..b81b70b64e7a5 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx @@ -15,7 +15,8 @@ import { import { ActionsLogUsersFilter } from './actions_log_users_filter'; import { MANAGEMENT_PATH } from '../../../../../common/constants'; -describe('Users filter', () => { +// FLAKY: https://github.com/elastic/kibana/issues/193554 +describe.skip('Users filter', () => { let render: ( props?: React.ComponentProps ) => ReturnType; From 39d42cee59ee835ccc11bfe32f942b16dda2c6bb Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:19:23 +0000 Subject: [PATCH 42/61] skip flaky suite (#193092) --- .../components/actions_log_users_filter.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx index b81b70b64e7a5..2c5152e3813f7 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/actions_log_users_filter.test.tsx @@ -16,6 +16,7 @@ import { ActionsLogUsersFilter } from './actions_log_users_filter'; import { MANAGEMENT_PATH } from '../../../../../common/constants'; // FLAKY: https://github.com/elastic/kibana/issues/193554 +// FLAKY: https://github.com/elastic/kibana/issues/193092 describe.skip('Users filter', () => { let render: ( props?: React.ComponentProps From 597c280e6adbb3648aa49b66a3a31ad65cf177a7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:22:47 +0000 Subject: [PATCH 43/61] skip flaky suite (#200091) --- x-pack/test/functional/apps/aiops/change_point_detection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/aiops/change_point_detection.ts b/x-pack/test/functional/apps/aiops/change_point_detection.ts index c0ac744e687b5..3f80d9e12e1ea 100644 --- a/x-pack/test/functional/apps/aiops/change_point_detection.ts +++ b/x-pack/test/functional/apps/aiops/change_point_detection.ts @@ -18,7 +18,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { // aiops lives in the ML UI so we need some related services. const ml = getService('ml'); - describe('change point detection', function () { + // FLAKY: https://github.com/elastic/kibana/issues/200091 + describe.skip('change point detection', function () { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); await ml.testResources.createDataViewIfNeeded('ft_ecommerce', 'order_date'); From 77ce624afeef666707a03570560b07aa871570ef Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:25:40 +0000 Subject: [PATCH 44/61] skip flaky suite (#187550) --- .../cypress/e2e/investigations/timelines/export.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts index 826ca78228b61..cb3e73011386b 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts @@ -23,7 +23,8 @@ import { expectedExportedTimeline } from '../../../objects/timeline'; import { closeToast } from '../../../tasks/common/toast'; import { getFullname } from '../../../tasks/common'; -describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { +// FLAKY: https://github.com/elastic/kibana/issues/187550 +describe.skip('Export timelines', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); deleteTimelines(); From 4726e708b3490a76f1377d1b63a9fcc7aace04a9 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:27:02 +0000 Subject: [PATCH 45/61] skip flaky suite (#195955) --- .../tests/apps/discover/async_search.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts b/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts index 1f768780a9c95..d4c87209c64c7 100644 --- a/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts +++ b/x-pack/test/search_sessions_integration/tests/apps/discover/async_search.ts @@ -29,7 +29,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const toasts = getService('toasts'); - describe('discover async search', () => { + // FLAKY: https://github.com/elastic/kibana/issues/195955 + describe.skip('discover async search', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await kibanaServer.importExport.load( From 2f02df5ccdbee70dbbe42e975ce1474d36bf54ea Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:28:09 +0000 Subject: [PATCH 46/61] skip flaky suite (#182603) --- .../functional/test_suites/common/discover/x_pack/reporting.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts index c944865d06327..0aff2b216b1b2 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/x_pack/reporting.ts @@ -97,7 +97,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Generate CSV: new search', () => { + // FLAKY: https://github.com/elastic/kibana/issues/182603 + describe.skip('Generate CSV: new search', () => { before(async () => { await reportingAPI.initEcommerce(); }); From 78626c8c5764e9393b34489e0ccc01d5895483e1 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:29:54 +0000 Subject: [PATCH 47/61] skip flaky suite (#200758) --- .../entity_store/trial_license_complete_tier/entity_store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts index 1fbaaa9b3fc71..8bad52ae41bdb 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts @@ -194,7 +194,8 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('status', () => { + // FLAKY: https://github.com/elastic/kibana/issues/200758 + describe.skip('status', () => { afterEach(async () => { await utils.cleanEngines(); }); From c65bc3bb613635bc0387868651210fc6d2bec58d Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 19 Nov 2024 18:42:16 +0000 Subject: [PATCH 48/61] skip flaky suite (#200787) --- .../scripts/verify_test_packages/verify_test_packages.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/scripts/verify_test_packages/verify_test_packages.test.ts b/x-pack/plugins/fleet/scripts/verify_test_packages/verify_test_packages.test.ts index b8d1ba7e9bec0..4f15ea3e6558f 100644 --- a/x-pack/plugins/fleet/scripts/verify_test_packages/verify_test_packages.test.ts +++ b/x-pack/plugins/fleet/scripts/verify_test_packages/verify_test_packages.test.ts @@ -23,7 +23,8 @@ mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ let mockedLogger: jest.Mocked; -describe('Test packages', () => { +// FLAKY: https://github.com/elastic/kibana/issues/200787 +describe.skip('Test packages', () => { beforeEach(() => { mockedLogger = loggerMock.create(); mockedAppContextService.getLogger.mockReturnValue(mockedLogger); From 523fd13925cd0ec4f02f9ac069b09153e36fb9e0 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 19 Nov 2024 13:49:44 -0500 Subject: [PATCH 49/61] [Fleet] use defaultFleetErrorHandler in all fleet routes (#200741) --- x-pack/plugins/fleet/dev_docs/fleet_router.md | 33 ++ .../server/routes/agent/actions_handlers.ts | 63 +-- .../fleet/server/routes/agent/handlers.ts | 299 ++++------ .../agent/request_diagnostics_handler.test.ts | 13 +- .../agent/request_diagnostics_handler.ts | 59 +- .../server/routes/agent/unenroll_handler.ts | 37 +- .../server/routes/agent/upgrade_handler.ts | 174 +++--- .../server/routes/agent_policy/handlers.ts | 532 ++++++++---------- .../plugins/fleet/server/routes/app/index.ts | 55 +- .../server/routes/data_streams/handlers.ts | 348 ++++++------ .../server/routes/download_source/handler.ts | 49 +- .../routes/enrollment_api_key/handler.ts | 74 ++- .../fleet/server/routes/epm/file_handler.ts | 163 +++--- .../fleet/server/routes/epm/handlers.ts | 421 ++++++-------- .../routes/epm/kibana_assets_handler.ts | 128 ++--- .../server/routes/fleet_proxies/handler.ts | 43 +- .../routes/fleet_server_hosts/handler.test.ts | 19 +- .../routes/fleet_server_hosts/handler.ts | 66 +-- .../server/routes/health_check/handler.ts | 4 +- .../message_signing_service/handlers.test.ts | 9 +- .../message_signing_service/handlers.ts | 3 +- .../server/routes/output/handler.test.ts | 30 +- .../fleet/server/routes/output/handler.ts | 89 ++- .../routes/package_policy/handlers.test.ts | 11 +- .../server/routes/package_policy/handlers.ts | 262 ++++----- .../server/routes/preconfiguration/handler.ts | 17 +- .../settings/enrollment_settings_handler.ts | 153 +++-- .../routes/settings/settings_handler.ts | 49 +- .../server/routes/setup/handlers.test.ts | 28 +- .../fleet/server/routes/setup/handlers.ts | 95 ++-- .../standalone_agent_api_key/handler.ts | 38 +- .../routes/uninstall_token/handlers.test.ts | 18 +- .../server/routes/uninstall_token/handlers.ts | 107 ++-- .../server/services/security/fleet_router.ts | 26 +- 34 files changed, 1624 insertions(+), 1891 deletions(-) create mode 100644 x-pack/plugins/fleet/dev_docs/fleet_router.md diff --git a/x-pack/plugins/fleet/dev_docs/fleet_router.md b/x-pack/plugins/fleet/dev_docs/fleet_router.md new file mode 100644 index 0000000000000..03df8c19318ee --- /dev/null +++ b/x-pack/plugins/fleet/dev_docs/fleet_router.md @@ -0,0 +1,33 @@ +# Fleet router + +All the fleet API routes are wrapped with a custom handler see [fleet_router](../server/services/security/fleet_router.ts) that provides error handling and security. + +## Error handling + +All non catched errors in Fleet API will go throuh a default error handler, that will allow to transform known error in response with predefined status code. + +## Security + +Fleet router also provide an easy way to declare authorization rules for Fleet routes. This can be done via the `fleetAuthz` property via a function or an object with required roles. + +Examples: + +```typescript +router.versioned.get({ + path: OUTPUT_API_ROUTES.LIST_PATTERN, + fleetAuthz: (authz) => { + return authz.fleet.readSettings || authz.fleet.readAgentPolicies; + }, + summary: 'Get outputs', +}); +``` + +```typescript +router.versioned.post({ + path: OUTPUT_API_ROUTES.CREATE_PATTERN, + fleetAuthz: { + fleet: { allSettings: true }, + }, + summary: 'Create output', +}); +``` diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index b11dcb719e2d2..1871bbd74fd17 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -16,7 +16,6 @@ import type { } from '../../types/rest_spec'; import type { ActionsService } from '../../services/agents'; import type { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; -import { defaultFleetErrorHandler } from '../../errors'; import { getCurrentNamespace } from '../../services/spaces/get_current_namespace'; export const postNewAgentActionHandlerBuilder = function ( @@ -27,30 +26,26 @@ export const postNewAgentActionHandlerBuilder = function ( TypeOf > { return async (context, request, response) => { - try { - const core = await context.core; - const esClient = core.elasticsearch.client.asInternalUser; - const soClient = core.savedObjects.client; + const core = await context.core; + const esClient = core.elasticsearch.client.asInternalUser; + const soClient = core.savedObjects.client; - const agent = await actionsService.getAgent(esClient, soClient, request.params.agentId); + const agent = await actionsService.getAgent(esClient, soClient, request.params.agentId); - const newAgentAction = request.body.action; + const newAgentAction = request.body.action; - const savedAgentAction = await actionsService.createAgentAction(esClient, { - created_at: new Date().toISOString(), - ...newAgentAction, - agents: [agent.id], - namespaces: [getCurrentNamespace(soClient)], - }); + const savedAgentAction = await actionsService.createAgentAction(esClient, { + created_at: new Date().toISOString(), + ...newAgentAction, + agents: [agent.id], + namespaces: [getCurrentNamespace(soClient)], + }); - const body: PostNewAgentActionResponse = { - item: savedAgentAction, - }; + const body: PostNewAgentActionResponse = { + item: savedAgentAction, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; }; @@ -58,24 +53,20 @@ export const postCancelActionHandlerBuilder = function ( actionsService: ActionsService ): RequestHandler, undefined, undefined> { return async (context, request, response) => { - try { - const core = await context.core; - const esClient = core.elasticsearch.client.asInternalUser; - const soClient = core.savedObjects.client; + const core = await context.core; + const esClient = core.elasticsearch.client.asInternalUser; + const soClient = core.savedObjects.client; - const action = await actionsService.cancelAgentAction( - esClient, - soClient, - request.params.actionId - ); + const action = await actionsService.cancelAgentAction( + esClient, + soClient, + request.params.actionId + ); - const body: PostNewAgentActionResponse = { - item: action, - }; + const body: PostNewAgentActionResponse = { + item: action, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; }; diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index f7f56b76503b4..bcefa56b806e8 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -40,7 +40,7 @@ import type { PostRetrieveAgentsByActionsRequestSchema, FleetRequestHandler, } from '../../types'; -import { defaultFleetErrorHandler, FleetNotFoundError } from '../../errors'; +import { FleetNotFoundError } from '../../errors'; import * as AgentService from '../../services/agents'; import { fetchAndAssignAgentMetrics } from '../../services/agents/agent_metrics'; import { getAgentStatusForAgentPolicy } from '../../services/agents'; @@ -81,7 +81,7 @@ export const getAgentHandler: FleetRequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -110,7 +110,7 @@ export const deleteAgentHandler: FleetRequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -148,7 +148,7 @@ export const updateAgentHandler: FleetRequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -164,19 +164,15 @@ export const bulkUpdateAgentTagsHandler: RequestHandler< ? { agentIds: request.body.agents } : { kuery: request.body.agents, showInactive: request.body.includeInactive }; - try { - const results = await AgentService.updateAgentTags( - soClient, - esClient, - { ...agentOptions, batchSize: request.body.batchSize }, - request.body.tagsToAdd ?? [], - request.body.tagsToRemove ?? [] - ); - - return response.ok({ body: { actionId: results.actionId } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const results = await AgentService.updateAgentTags( + soClient, + esClient, + { ...agentOptions, batchSize: request.body.batchSize }, + request.body.tagsToAdd ?? [], + request.body.tagsToRemove ?? [] + ); + + return response.ok({ body: { actionId: results.actionId } }); }; export const getAgentsHandler: FleetRequestHandler< @@ -187,37 +183,33 @@ export const getAgentsHandler: FleetRequestHandler< const { agentClient } = fleetContext; const esClientCurrentUser = coreContext.elasticsearch.client.asCurrentUser; - try { - const agentRes = await agentClient.asCurrentUser.listAgents({ - page: request.query.page, - perPage: request.query.perPage, - showInactive: request.query.showInactive, - showUpgradeable: request.query.showUpgradeable, - kuery: request.query.kuery, - sortField: request.query.sortField, - sortOrder: request.query.sortOrder, - getStatusSummary: request.query.getStatusSummary, - }); - - const { total, page, perPage, statusSummary } = agentRes; - let { agents } = agentRes; - - // Assign metrics - if (request.query.withMetrics) { - agents = await fetchAndAssignAgentMetrics(esClientCurrentUser, agents); - } - - const body: GetAgentsResponse = { - items: agents, - total, - page, - perPage, - ...(statusSummary ? { statusSummary } : {}), - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + const agentRes = await agentClient.asCurrentUser.listAgents({ + page: request.query.page, + perPage: request.query.perPage, + showInactive: request.query.showInactive, + showUpgradeable: request.query.showUpgradeable, + kuery: request.query.kuery, + sortField: request.query.sortField, + sortOrder: request.query.sortOrder, + getStatusSummary: request.query.getStatusSummary, + }); + + const { total, page, perPage, statusSummary } = agentRes; + let { agents } = agentRes; + + // Assign metrics + if (request.query.withMetrics) { + agents = await fetchAndAssignAgentMetrics(esClientCurrentUser, agents); } + + const body: GetAgentsResponse = { + items: agents, + total, + page, + perPage, + ...(statusSummary ? { statusSummary } : {}), + }; + return response.ok({ body }); }; export const getAgentTagsHandler: RequestHandler< @@ -228,19 +220,15 @@ export const getAgentTagsHandler: RequestHandler< const esClient = coreContext.elasticsearch.client.asInternalUser; const soClient = coreContext.savedObjects.client; - try { - const tags = await AgentService.getAgentTags(soClient, esClient, { - showInactive: request.query.showInactive, - kuery: request.query.kuery, - }); + const tags = await AgentService.getAgentTags(soClient, esClient, { + showInactive: request.query.showInactive, + kuery: request.query.kuery, + }); - const body: GetAgentTagsResponse = { - items: tags, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const body: GetAgentTagsResponse = { + items: tags, + }; + return response.ok({ body }); }; export const postAgentReassignHandler: RequestHandler< @@ -251,19 +239,15 @@ export const postAgentReassignHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - await AgentService.reassignAgent( - soClient, - esClient, - request.params.agentId, - request.body.policy_id - ); - - const body: PostAgentReassignResponse = {}; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + await AgentService.reassignAgent( + soClient, + esClient, + request.params.agentId, + request.body.policy_id + ); + + const body: PostAgentReassignResponse = {}; + return response.ok({ body }); }; export const postBulkAgentReassignHandler: RequestHandler< @@ -278,52 +262,44 @@ export const postBulkAgentReassignHandler: RequestHandler< ? { agentIds: request.body.agents } : { kuery: request.body.agents, showInactive: request.body.includeInactive }; - try { - const results = await AgentService.reassignAgents( - soClient, - esClient, - { ...agentOptions, batchSize: request.body.batchSize }, - request.body.policy_id - ); - - return response.ok({ body: { actionId: results.actionId } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const results = await AgentService.reassignAgents( + soClient, + esClient, + { ...agentOptions, batchSize: request.body.batchSize }, + request.body.policy_id + ); + + return response.ok({ body: { actionId: results.actionId } }); }; export const getAgentStatusForAgentPolicyHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]); - const esClient = coreContext.elasticsearch.client.asInternalUser; - const soClient = fleetContext.internalSoClient; + const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]); + const esClient = coreContext.elasticsearch.client.asInternalUser; + const soClient = fleetContext.internalSoClient; - const parsePolicyIds = (policyIds: string | string[] | undefined): string[] | undefined => { - if (!policyIds || !policyIds.length) { - return undefined; - } + const parsePolicyIds = (policyIds: string | string[] | undefined): string[] | undefined => { + if (!policyIds || !policyIds.length) { + return undefined; + } - return Array.isArray(policyIds) ? policyIds : [policyIds]; - }; + return Array.isArray(policyIds) ? policyIds : [policyIds]; + }; - const results = await getAgentStatusForAgentPolicy( - esClient, - soClient, - request.query.policyId, - request.query.kuery, - coreContext.savedObjects.client.getCurrentNamespace(), - parsePolicyIds(request.query.policyIds) - ); + const results = await getAgentStatusForAgentPolicy( + esClient, + soClient, + request.query.policyId, + request.query.kuery, + coreContext.savedObjects.client.getCurrentNamespace(), + parsePolicyIds(request.query.policyIds) + ); - const body: GetAgentStatusResponse = { results: omit(results, 'total') }; + const body: GetAgentStatusResponse = { results: omit(results, 'total') }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getAgentDataHandler: RequestHandler< @@ -332,24 +308,21 @@ export const getAgentDataHandler: RequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asCurrentUser; - try { - const returnDataPreview = request.query.previewData; - const agentIds = isStringArray(request.query.agentsIds) - ? request.query.agentsIds - : [request.query.agentsIds]; - const { items, dataPreview } = await AgentService.getIncomingDataByAgentsId( - esClient, - agentIds, - returnDataPreview - ); + const returnDataPreview = request.query.previewData; + const agentIds = isStringArray(request.query.agentsIds) + ? request.query.agentsIds + : [request.query.agentsIds]; - const body = { items, dataPreview }; + const { items, dataPreview } = await AgentService.getIncomingDataByAgentsId( + esClient, + agentIds, + returnDataPreview + ); - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const body = { items, dataPreview }; + + return response.ok({ body }); }; function isStringArray(arr: unknown | string[]): arr is string[] { @@ -361,24 +334,16 @@ export const getAgentStatusRuntimeFieldHandler: RequestHandler = async ( request, response ) => { - try { - const runtimeFields = await buildAgentStatusRuntimeField(); + const runtimeFields = await buildAgentStatusRuntimeField(); - return response.ok({ body: (runtimeFields.status.script as Script)!.source! }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: (runtimeFields.status.script as Script)!.source! }); }; export const getAvailableVersionsHandler: RequestHandler = async (context, request, response) => { - try { - const availableVersions = await AgentService.getAvailableVersions(); - const body: GetAvailableVersionsResponse = { items: availableVersions }; + const availableVersions = await AgentService.getAvailableVersions(); + const body: GetAvailableVersionsResponse = { items: availableVersions }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getActionStatusHandler: RequestHandler< @@ -388,17 +353,13 @@ export const getActionStatusHandler: RequestHandler< const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const actionStatuses = await AgentService.getActionStatuses( - esClient, - request.query, - getCurrentNamespace(coreContext.savedObjects.client) - ); - const body: GetActionStatusResponse = { items: actionStatuses }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const actionStatuses = await AgentService.getActionStatuses( + esClient, + request.query, + getCurrentNamespace(coreContext.savedObjects.client) + ); + const body: GetActionStatusResponse = { items: actionStatuses }; + return response.ok({ body }); }; export const postRetrieveAgentsByActionsHandler: RequestHandler< @@ -409,13 +370,9 @@ export const postRetrieveAgentsByActionsHandler: RequestHandler< const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const agents = await AgentService.getAgentsByActionsIds(esClient, request.body.actionIds); - const body: PostRetrieveAgentsByActionsResponse = { items: agents }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const agents = await AgentService.getAgentsByActionsIds(esClient, request.body.actionIds); + const body: PostRetrieveAgentsByActionsResponse = { items: agents }; + return response.ok({ body }); }; export const getAgentUploadsHandler: RequestHandler< @@ -423,15 +380,11 @@ export const getAgentUploadsHandler: RequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const body: GetAgentUploadsResponse = { - items: await AgentService.getAgentUploads(esClient, request.params.agentId), - }; + const body: GetAgentUploadsResponse = { + items: await AgentService.getAgentUploads(esClient, request.params.agentId), + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getAgentUploadFileHandler: RequestHandler< @@ -439,17 +392,13 @@ export const getAgentUploadFileHandler: RequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const resp = await AgentService.getAgentUploadFile( - esClient, - request.params.fileId, - request.params.fileName - ); + const resp = await AgentService.getAgentUploadFile( + esClient, + request.params.fileId, + request.params.fileName + ); - return response.ok(resp); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok(resp); }; export const deleteAgentUploadFileHandler: RequestHandler< @@ -457,11 +406,7 @@ export const deleteAgentUploadFileHandler: RequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const resp = await AgentService.deleteAgentUploadFile(esClient, request.params.fileId); + const resp = await AgentService.deleteAgentUploadFile(esClient, request.params.fileId); - return response.ok({ body: resp }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: resp }); }; diff --git a/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.test.ts b/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.test.ts index b0502c6a23cd5..fccc5790df087 100644 --- a/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.test.ts +++ b/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.test.ts @@ -7,7 +7,6 @@ import type { ElasticsearchClient, KibanaResponseFactory, - RequestHandlerContext, SavedObjectsClientContract, KibanaRequest, } from '@kbn/core/server'; @@ -18,12 +17,14 @@ import { } from '@kbn/core/server/mocks'; import type { RequestDiagnosticsAdditionalMetrics } from '../../../common/types'; - +import { withDefaultErrorHandler } from '../../services/security/fleet_router'; import { getAgentById } from '../../services/agents'; import * as AgentService from '../../services/agents'; +import { type FleetRequestHandlerContext } from '../..'; import { requestDiagnosticsHandler } from './request_diagnostics_handler'; +const requestDiagnosticsWithErrorHandler = withDefaultErrorHandler(requestDiagnosticsHandler); jest.mock('../../services/agents'); const mockGetAgentById = getAgentById as jest.Mock; @@ -32,7 +33,7 @@ describe('request diagnostics handler', () => { let mockResponse: jest.Mocked; let mockSavedObjectsClient: jest.Mocked; let mockElasticsearchClient: jest.Mocked; - let mockContext: RequestHandlerContext; + let mockContext: FleetRequestHandlerContext; let mockRequest: KibanaRequest< { agentId: string }, undefined, @@ -56,7 +57,7 @@ describe('request diagnostics handler', () => { }, }, }, - } as unknown as RequestHandlerContext; + } as unknown as FleetRequestHandlerContext; mockRequest = httpServerMock.createKibanaRequest({ params: { agentId: 'agent1' }, body: { additional_metrics: ['CPU'] }, @@ -69,7 +70,7 @@ describe('request diagnostics handler', () => { local_metadata: { elastic: { agent: { version: '8.7.0' } } }, }); - await requestDiagnosticsHandler(mockContext, mockRequest, mockResponse); + await requestDiagnosticsWithErrorHandler(mockContext, mockRequest, mockResponse); expect(mockResponse.ok).toHaveBeenCalledWith({ body: { actionId: '1' } }); }); @@ -80,7 +81,7 @@ describe('request diagnostics handler', () => { local_metadata: { elastic: { agent: { version: '8.6.0' } } }, }); - await requestDiagnosticsHandler(mockContext, mockRequest, mockResponse); + await requestDiagnosticsWithErrorHandler(mockContext, mockRequest, mockResponse); expect(mockResponse.customError).toHaveBeenCalledWith({ body: { message: 'Agent agent1 does not support request diagnostics action.' }, diff --git a/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts b/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts index b59d38d947860..63177e6eb57c1 100644 --- a/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts @@ -5,20 +5,19 @@ * 2.0. */ -import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; import { isAgentRequestDiagnosticsSupported } from '../../../common/services'; import * as AgentService from '../../services/agents'; import type { + FleetRequestHandler, PostBulkRequestDiagnosticsActionRequestSchema, PostRequestDiagnosticsActionRequestSchema, } from '../../types'; -import { defaultFleetErrorHandler } from '../../errors'; import { getAgentById } from '../../services/agents'; -export const requestDiagnosticsHandler: RequestHandler< +export const requestDiagnosticsHandler: FleetRequestHandler< TypeOf, undefined, TypeOf @@ -26,32 +25,28 @@ export const requestDiagnosticsHandler: RequestHandler< const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; const soClient = coreContext.savedObjects.client; - try { - const agent = await getAgentById(esClient, soClient, request.params.agentId); + const agent = await getAgentById(esClient, soClient, request.params.agentId); - if (!isAgentRequestDiagnosticsSupported(agent)) { - return response.customError({ - statusCode: 400, - body: { - message: `Agent ${request.params.agentId} does not support request diagnostics action.`, - }, - }); - } + if (!isAgentRequestDiagnosticsSupported(agent)) { + return response.customError({ + statusCode: 400, + body: { + message: `Agent ${request.params.agentId} does not support request diagnostics action.`, + }, + }); + } - const result = await AgentService.requestDiagnostics( - esClient, - soClient, - request.params.agentId, - request.body?.additional_metrics - ); + const result = await AgentService.requestDiagnostics( + esClient, + soClient, + request.params.agentId, + request.body?.additional_metrics + ); - return response.ok({ body: { actionId: result.actionId } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: { actionId: result.actionId } }); }; -export const bulkRequestDiagnosticsHandler: RequestHandler< +export const bulkRequestDiagnosticsHandler: FleetRequestHandler< undefined, undefined, TypeOf @@ -62,15 +57,11 @@ export const bulkRequestDiagnosticsHandler: RequestHandler< const agentOptions = Array.isArray(request.body.agents) ? { agentIds: request.body.agents } : { kuery: request.body.agents }; - try { - const result = await AgentService.bulkRequestDiagnostics(esClient, soClient, { - ...agentOptions, - batchSize: request.body.batchSize, - additionalMetrics: request.body.additional_metrics, - }); + const result = await AgentService.bulkRequestDiagnostics(esClient, soClient, { + ...agentOptions, + batchSize: request.body.batchSize, + additionalMetrics: request.body.additional_metrics, + }); - return response.ok({ body: { actionId: result.actionId } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: { actionId: result.actionId } }); }; diff --git a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts index ec72f23a9876d..6f8b4c3d82fae 100644 --- a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts @@ -14,7 +14,6 @@ import type { PostBulkAgentUnenrollRequestSchema, } from '../../types'; import * as AgentService from '../../services/agents'; -import { defaultFleetErrorHandler } from '../../errors'; export const postAgentUnenrollHandler: RequestHandler< TypeOf, @@ -24,17 +23,13 @@ export const postAgentUnenrollHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - await AgentService.unenrollAgent(soClient, esClient, request.params.agentId, { - force: request.body?.force, - revoke: request.body?.revoke, - }); + await AgentService.unenrollAgent(soClient, esClient, request.params.agentId, { + force: request.body?.force, + revoke: request.body?.revoke, + }); - const body: PostAgentUnenrollResponse = {}; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const body: PostAgentUnenrollResponse = {}; + return response.ok({ body }); }; export const postBulkAgentsUnenrollHandler: RequestHandler< @@ -49,17 +44,13 @@ export const postBulkAgentsUnenrollHandler: RequestHandler< ? { agentIds: request.body.agents } : { kuery: request.body.agents }; - try { - const results = await AgentService.unenrollAgents(soClient, esClient, { - ...agentOptions, - revoke: request.body?.revoke, - force: request.body?.force, - batchSize: request.body?.batchSize, - showInactive: request.body?.includeInactive, - }); + const results = await AgentService.unenrollAgents(soClient, esClient, { + ...agentOptions, + revoke: request.body?.revoke, + force: request.body?.force, + batchSize: request.body?.batchSize, + showInactive: request.body?.includeInactive, + }); - return response.ok({ body: { actionId: results.actionId } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: { actionId: results.actionId } }); }; diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index c2a4361a8253e..aaacd5b4a1c59 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -19,7 +19,7 @@ import type { PostAgentUpgradeResponse } from '../../../common/types'; import type { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; -import { defaultFleetErrorHandler, AgentRequestInvalidError } from '../../errors'; +import { AgentRequestInvalidError } from '../../errors'; import { getRecentUpgradeInfoForAgent, AGENT_UPGRADE_COOLDOWN_IN_MIN, @@ -55,88 +55,84 @@ export const postAgentUpgradeHandler: RequestHandler< }, }); } - try { - const agent = await getAgentById(esClient, soClient, request.params.agentId); - - const fleetServerAgents = await getAllFleetServerAgents(soClient, esClient); - const agentIsFleetServer = fleetServerAgents.some( - (fleetServerAgent) => fleetServerAgent.id === agent.id - ); - if (!agentIsFleetServer) { - try { - checkFleetServerVersion(version, fleetServerAgents); - } catch (err) { - return response.customError({ - statusCode: 400, - body: { - message: err.message, - }, - }); - } - } - - const { hasBeenUpgradedRecently, timeToWaitMs } = getRecentUpgradeInfoForAgent(agent); - const timeToWaitString = moment - .utc(moment.duration(timeToWaitMs).asMilliseconds()) - .format('mm[m]ss[s]'); - - if (!skipRateLimitCheck && hasBeenUpgradedRecently) { - return response.customError({ - statusCode: 429, - body: { - message: `agent ${request.params.agentId} was upgraded less than ${AGENT_UPGRADE_COOLDOWN_IN_MIN} minutes ago. Please wait ${timeToWaitString} before trying again to ensure the upgrade will not be rolled back.`, - }, - headers: { - // retry-after expects seconds - 'retry-after': Math.ceil(timeToWaitMs / 1000).toString(), - }, - }); - } - - if (agent.unenrollment_started_at || agent.unenrolled_at) { + const agent = await getAgentById(esClient, soClient, request.params.agentId); + + const fleetServerAgents = await getAllFleetServerAgents(soClient, esClient); + const agentIsFleetServer = fleetServerAgents.some( + (fleetServerAgent) => fleetServerAgent.id === agent.id + ); + if (!agentIsFleetServer) { + try { + checkFleetServerVersion(version, fleetServerAgents); + } catch (err) { return response.customError({ statusCode: 400, body: { - message: 'cannot upgrade an unenrolling or unenrolled agent', + message: err.message, }, }); } + } - if (!force && isAgentUpgrading(agent)) { - return response.customError({ - statusCode: 400, - body: { - message: `agent ${request.params.agentId} is already upgrading`, - }, - }); - } + const { hasBeenUpgradedRecently, timeToWaitMs } = getRecentUpgradeInfoForAgent(agent); + const timeToWaitString = moment + .utc(moment.duration(timeToWaitMs).asMilliseconds()) + .format('mm[m]ss[s]'); - if (!force && !skipRateLimitCheck && !isAgentUpgradeableToVersion(agent, version)) { - return response.customError({ - statusCode: 400, - body: { - message: `Agent ${request.params.agentId} is not upgradeable: ${getNotUpgradeableMessage( - agent, - latestAgentVersion, - version - )}`, - }, - }); - } + if (!skipRateLimitCheck && hasBeenUpgradedRecently) { + return response.customError({ + statusCode: 429, + body: { + message: `agent ${request.params.agentId} was upgraded less than ${AGENT_UPGRADE_COOLDOWN_IN_MIN} minutes ago. Please wait ${timeToWaitString} before trying again to ensure the upgrade will not be rolled back.`, + }, + headers: { + // retry-after expects seconds + 'retry-after': Math.ceil(timeToWaitMs / 1000).toString(), + }, + }); + } - await AgentService.sendUpgradeAgentAction({ - soClient, - esClient, - agentId: request.params.agentId, - version, - sourceUri, + if (agent.unenrollment_started_at || agent.unenrolled_at) { + return response.customError({ + statusCode: 400, + body: { + message: 'cannot upgrade an unenrolling or unenrolled agent', + }, }); + } + + if (!force && isAgentUpgrading(agent)) { + return response.customError({ + statusCode: 400, + body: { + message: `agent ${request.params.agentId} is already upgrading`, + }, + }); + } - const body: PostAgentUpgradeResponse = {}; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + if (!force && !skipRateLimitCheck && !isAgentUpgradeableToVersion(agent, version)) { + return response.customError({ + statusCode: 400, + body: { + message: `Agent ${request.params.agentId} is not upgradeable: ${getNotUpgradeableMessage( + agent, + latestAgentVersion, + version + )}`, + }, + }); } + + await AgentService.sendUpgradeAgentAction({ + soClient, + esClient, + agentId: request.params.agentId, + version, + sourceUri, + }); + + const body: PostAgentUpgradeResponse = {}; + return response.ok({ body }); }; export const postBulkAgentsUpgradeHandler: RequestHandler< @@ -171,26 +167,22 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< }); } - try { - const agentOptions = Array.isArray(agents) - ? { agentIds: agents } - : { kuery: agents, showInactive: request.body.includeInactive }; - const upgradeOptions = { - ...agentOptions, - sourceUri, - version, - force, - skipRateLimitCheck, - upgradeDurationSeconds, - startTime, - batchSize, - }; - const results = await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); - - return response.ok({ body: { actionId: results.actionId } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const agentOptions = Array.isArray(agents) + ? { agentIds: agents } + : { kuery: agents, showInactive: request.body.includeInactive }; + const upgradeOptions = { + ...agentOptions, + sourceUri, + version, + force, + skipRateLimitCheck, + upgradeDurationSeconds, + startTime, + batchSize, + }; + const results = await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); + + return response.ok({ body: { actionId: results.actionId } }); }; export const checkKibanaVersion = (version: string, kibanaVersion: string, force = false) => { diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index 713c054d8105e..faa18c0cfa476 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -52,12 +52,7 @@ import type { GetAgentPolicyOutputsResponse, GetListAgentPolicyOutputsResponse, } from '../../../common/types'; -import { - defaultFleetErrorHandler, - AgentPolicyNotFoundError, - FleetUnauthorizedError, - FleetError, -} from '../../errors'; +import { AgentPolicyNotFoundError, FleetUnauthorizedError, FleetError } from '../../errors'; import { createAgentPolicyWithPackages } from '../../services/agent_policy_create'; import { updateAgentPolicySpaces } from '../../services/spaces/agent_policy'; import { packagePolicyToSimplifiedPackagePolicy } from '../../../common/services/simplified_package_policy_helper'; @@ -131,60 +126,56 @@ export const getAgentPoliciesHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]); - const soClient = fleetContext.internalSoClient; - const esClient = coreContext.elasticsearch.client.asInternalUser; - const { - full: withPackagePolicies = false, - noAgentCount, - withAgentCount, - format, - ...restOfQuery - } = request.query; - if (!fleetContext.authz.fleet.readAgentPolicies && withPackagePolicies) { - throw new FleetUnauthorizedError( - 'full query parameter require agent policies read permissions' - ); - } - const agentPoliciesResponse = await agentPolicyService.list(soClient, { - withPackagePolicies, - esClient, - ...restOfQuery, - }); - let { items } = agentPoliciesResponse; - const { total, page, perPage } = agentPoliciesResponse; - - if (fleetContext.authz.fleet.readAgents && (noAgentCount === false || withAgentCount)) { - await populateAssignedAgentsCount(fleetContext.agentClient.asCurrentUser, items); - } + const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]); + const soClient = fleetContext.internalSoClient; + const esClient = coreContext.elasticsearch.client.asInternalUser; + const { + full: withPackagePolicies = false, + noAgentCount, + withAgentCount, + format, + ...restOfQuery + } = request.query; + if (!fleetContext.authz.fleet.readAgentPolicies && withPackagePolicies) { + throw new FleetUnauthorizedError( + 'full query parameter require agent policies read permissions' + ); + } + const agentPoliciesResponse = await agentPolicyService.list(soClient, { + withPackagePolicies, + esClient, + ...restOfQuery, + }); + let { items } = agentPoliciesResponse; + const { total, page, perPage } = agentPoliciesResponse; - if (!fleetContext.authz.fleet.readAgentPolicies) { - items = items.map(sanitizeItemForReadAgentOnly); - } else if (withPackagePolicies && format === inputsFormat.Simplified) { - items.map((item) => { - if (isEmpty(item.package_policies)) { - return item; - } - return { - ...item, - package_policies: item.package_policies!.map((packagePolicy) => - packagePolicyToSimplifiedPackagePolicy(packagePolicy) - ), - }; - }); - } + if (fleetContext.authz.fleet.readAgents && (noAgentCount === false || withAgentCount)) { + await populateAssignedAgentsCount(fleetContext.agentClient.asCurrentUser, items); + } - const body: GetAgentPoliciesResponse = { - items, - total, - page, - perPage, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + if (!fleetContext.authz.fleet.readAgentPolicies) { + items = items.map(sanitizeItemForReadAgentOnly); + } else if (withPackagePolicies && format === inputsFormat.Simplified) { + items.map((item) => { + if (isEmpty(item.package_policies)) { + return item; + } + return { + ...item, + package_policies: item.package_policies!.map((packagePolicy) => + packagePolicyToSimplifiedPackagePolicy(packagePolicy) + ), + }; + }); } + + const body: GetAgentPoliciesResponse = { + items, + total, + page, + perPage, + }; + return response.ok({ body }); }; export const bulkGetAgentPoliciesHandler: FleetRequestHandler< @@ -238,7 +229,7 @@ export const bulkGetAgentPoliciesHandler: FleetRequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -246,43 +237,39 @@ export const getOneAgentPolicyHandler: FleetRequestHandler< TypeOf, TypeOf > = async (context, request, response) => { - try { - const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]); - const soClient = coreContext.savedObjects.client; + const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]); + const soClient = coreContext.savedObjects.client; - const agentPolicy = await agentPolicyService.get(soClient, request.params.agentPolicyId); - if (agentPolicy) { - if (fleetContext.authz.fleet.readAgents) { - await populateAssignedAgentsCount(fleetContext.agentClient.asCurrentUser, [agentPolicy]); - } - let item: any = agentPolicy; - if (!fleetContext.authz.fleet.readAgentPolicies) { - item = sanitizeItemForReadAgentOnly(agentPolicy); - } else if ( - request.query.format === inputsFormat.Simplified && - !isEmpty(agentPolicy.package_policies) - ) { - item = { - ...agentPolicy, - package_policies: agentPolicy.package_policies!.map((packagePolicy) => - packagePolicyToSimplifiedPackagePolicy(packagePolicy) - ), - }; - } - const body: GetOneAgentPolicyResponse = { - item, + const agentPolicy = await agentPolicyService.get(soClient, request.params.agentPolicyId); + if (agentPolicy) { + if (fleetContext.authz.fleet.readAgents) { + await populateAssignedAgentsCount(fleetContext.agentClient.asCurrentUser, [agentPolicy]); + } + let item: any = agentPolicy; + if (!fleetContext.authz.fleet.readAgentPolicies) { + item = sanitizeItemForReadAgentOnly(agentPolicy); + } else if ( + request.query.format === inputsFormat.Simplified && + !isEmpty(agentPolicy.package_policies) + ) { + item = { + ...agentPolicy, + package_policies: agentPolicy.package_policies!.map((packagePolicy) => + packagePolicyToSimplifiedPackagePolicy(packagePolicy) + ), }; - return response.ok({ - body, - }); - } else { - return response.customError({ - statusCode: 404, - body: { message: 'Agent policy not found' }, - }); } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + const body: GetOneAgentPolicyResponse = { + item, + }; + return response.ok({ + body, + }); + } else { + return response.customError({ + statusCode: 404, + body: { message: 'Agent policy not found' }, + }); } }; @@ -357,7 +344,7 @@ export const createAgentPolicyHandler: FleetRequestHandler< body: { message: error.message }, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -423,7 +410,7 @@ export const updateAgentPolicyHandler: FleetRequestHandler< body: { message: error.message }, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -463,7 +450,7 @@ export const copyAgentPolicyHandler: RequestHandler< body, }); } catch (error) { - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -476,19 +463,16 @@ export const deleteAgentPoliciesHandler: RequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; const user = appContextService.getSecurityCore().authc.getCurrentUser(request) || undefined; - try { - const body: DeleteAgentPolicyResponse = await agentPolicyService.delete( - soClient, - esClient, - request.body.agentPolicyId, - { user, force: request.body.force } - ); - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + + const body: DeleteAgentPolicyResponse = await agentPolicyService.delete( + soClient, + esClient, + request.body.agentPolicyId, + { user, force: request.body.force } + ); + return response.ok({ + body, + }); }; export const getFullAgentPolicy: FleetRequestHandler< @@ -499,55 +483,47 @@ export const getFullAgentPolicy: FleetRequestHandler< const soClient = fleetContext.internalSoClient; if (request.query.kubernetes === true) { - try { - const agentVersion = - await fleetContext.agentClient.asInternalUser.getLatestAgentAvailableVersion(); - const fullAgentConfigMap = await agentPolicyService.getFullAgentConfigMap( - soClient, - request.params.agentPolicyId, - agentVersion, - { standalone: request.query.standalone === true } - ); - if (fullAgentConfigMap) { - const body: GetFullAgentConfigMapResponse = { - item: fullAgentConfigMap, - }; - return response.ok({ - body, - }); - } else { - return response.customError({ - statusCode: 404, - body: { message: 'Agent config map not found' }, - }); - } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + const agentVersion = + await fleetContext.agentClient.asInternalUser.getLatestAgentAvailableVersion(); + const fullAgentConfigMap = await agentPolicyService.getFullAgentConfigMap( + soClient, + request.params.agentPolicyId, + agentVersion, + { standalone: request.query.standalone === true } + ); + if (fullAgentConfigMap) { + const body: GetFullAgentConfigMapResponse = { + item: fullAgentConfigMap, + }; + return response.ok({ + body, + }); + } else { + return response.customError({ + statusCode: 404, + body: { message: 'Agent config map not found' }, + }); } } else { - try { - const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy( - soClient, - request.params.agentPolicyId, - { - standalone: request.query.standalone === true, - } - ); - if (fullAgentPolicy) { - const body: GetFullAgentPolicyResponse = { - item: fullAgentPolicy, - }; - return response.ok({ - body, - }); - } else { - return response.customError({ - statusCode: 404, - body: { message: 'Agent policy not found' }, - }); + const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy( + soClient, + request.params.agentPolicyId, + { + standalone: request.query.standalone === true, } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + ); + if (fullAgentPolicy) { + const body: GetFullAgentPolicyResponse = { + item: fullAgentPolicy, + }; + return response.ok({ + body, + }); + } else { + return response.customError({ + statusCode: 404, + body: { message: 'Agent policy not found' }, + }); } } }; @@ -563,112 +539,39 @@ export const downloadFullAgentPolicy: FleetRequestHandler< } = request; if (request.query.kubernetes === true) { - try { - const agentVersion = - await fleetContext.agentClient.asInternalUser.getLatestAgentAvailableVersion(); - const fullAgentConfigMap = await agentPolicyService.getFullAgentConfigMap( - soClient, - request.params.agentPolicyId, - agentVersion, - { standalone: request.query.standalone === true } - ); - if (fullAgentConfigMap) { - const body = fullAgentConfigMap; - const headers: ResponseHeaders = { - 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent-standalone-kubernetes.yml"`, - }; - return response.ok({ - body, - headers, - }); - } else { - return response.customError({ - statusCode: 404, - body: { message: 'Agent config map not found' }, - }); - } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } - } else { - try { - const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId, { - standalone: request.query.standalone === true, - }); - if (fullAgentPolicy) { - const body = fullAgentPolicyToYaml(fullAgentPolicy, dump); - const headers: ResponseHeaders = { - 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent.yml"`, - }; - return response.ok({ - body, - headers, - }); - } else { - return response.customError({ - statusCode: 404, - body: { message: 'Agent policy not found' }, - }); - } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } - } -}; - -export const getK8sManifest: FleetRequestHandler< - undefined, - TypeOf -> = async (context, request, response) => { - try { - const fleetServer = request.query.fleetServer ?? ''; - const token = request.query.enrolToken ?? ''; - - const agentVersion = await getLatestAvailableAgentVersion(); - - const fullAgentManifest = await agentPolicyService.getFullAgentManifest( - fleetServer, - token, - agentVersion + const agentVersion = + await fleetContext.agentClient.asInternalUser.getLatestAgentAvailableVersion(); + const fullAgentConfigMap = await agentPolicyService.getFullAgentConfigMap( + soClient, + request.params.agentPolicyId, + agentVersion, + { standalone: request.query.standalone === true } ); - if (fullAgentManifest) { - const body: GetFullAgentManifestResponse = { - item: fullAgentManifest, + if (fullAgentConfigMap) { + const body = fullAgentConfigMap; + const headers: ResponseHeaders = { + 'content-type': 'text/x-yaml', + 'content-disposition': `attachment; filename="elastic-agent-standalone-kubernetes.yml"`, }; return response.ok({ body, + headers, }); } else { return response.customError({ statusCode: 404, - body: { message: 'Agent manifest not found' }, + body: { message: 'Agent config map not found' }, }); } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } -}; - -export const downloadK8sManifest: FleetRequestHandler< - undefined, - TypeOf -> = async (context, request, response) => { - try { - const fleetServer = request.query.fleetServer ?? ''; - const token = request.query.enrolToken ?? ''; - const agentVersion = await getLatestAvailableAgentVersion(); - const fullAgentManifest = await agentPolicyService.getFullAgentManifest( - fleetServer, - token, - agentVersion - ); - if (fullAgentManifest) { - const body = fullAgentManifest; + } else { + const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId, { + standalone: request.query.standalone === true, + }); + if (fullAgentPolicy) { + const body = fullAgentPolicyToYaml(fullAgentPolicy, dump); const headers: ResponseHeaders = { 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent-managed-kubernetes.yml"`, + 'content-disposition': `attachment; filename="elastic-agent.yml"`, }; return response.ok({ body, @@ -677,71 +580,120 @@ export const downloadK8sManifest: FleetRequestHandler< } else { return response.customError({ statusCode: 404, - body: { message: 'Agent manifest not found' }, + body: { message: 'Agent policy not found' }, }); } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); } }; -export const GetAgentPolicyOutputsHandler: FleetRequestHandler< - TypeOf, - undefined +export const getK8sManifest: FleetRequestHandler< + undefined, + TypeOf > = async (context, request, response) => { - try { - const coreContext = await context.core; - const soClient = coreContext.savedObjects.client; - const agentPolicy = await agentPolicyService.get(soClient, request.params.agentPolicyId); + const fleetServer = request.query.fleetServer ?? ''; + const token = request.query.enrolToken ?? ''; - if (!agentPolicy) { - return response.customError({ - statusCode: 404, - body: { message: 'Agent policy not found' }, - }); - } - const outputs = await agentPolicyService.getAllOutputsForPolicy(soClient, agentPolicy); + const agentVersion = await getLatestAvailableAgentVersion(); - const body: GetAgentPolicyOutputsResponse = { - item: outputs, + const fullAgentManifest = await agentPolicyService.getFullAgentManifest( + fleetServer, + token, + agentVersion + ); + if (fullAgentManifest) { + const body: GetFullAgentManifestResponse = { + item: fullAgentManifest, }; return response.ok({ body, }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + } else { + return response.customError({ + statusCode: 404, + body: { message: 'Agent manifest not found' }, + }); } }; -export const GetListAgentPolicyOutputsHandler: FleetRequestHandler< - undefined, +export const downloadK8sManifest: FleetRequestHandler< undefined, - TypeOf + TypeOf > = async (context, request, response) => { - try { - const coreContext = await context.core; - const soClient = coreContext.savedObjects.client; - const { ids } = request.body; + const fleetServer = request.query.fleetServer ?? ''; + const token = request.query.enrolToken ?? ''; + const agentVersion = await getLatestAvailableAgentVersion(); + const fullAgentManifest = await agentPolicyService.getFullAgentManifest( + fleetServer, + token, + agentVersion + ); + if (fullAgentManifest) { + const body = fullAgentManifest; + const headers: ResponseHeaders = { + 'content-type': 'text/x-yaml', + 'content-disposition': `attachment; filename="elastic-agent-managed-kubernetes.yml"`, + }; + return response.ok({ + body, + headers, + }); + } else { + return response.customError({ + statusCode: 404, + body: { message: 'Agent manifest not found' }, + }); + } +}; - if (!ids) { - return response.ok({ - body: { items: [] }, - }); - } - const agentPolicies = await agentPolicyService.getByIDs(soClient, ids, { - withPackagePolicies: true, +export const GetAgentPolicyOutputsHandler: FleetRequestHandler< + TypeOf, + undefined +> = async (context, request, response) => { + const coreContext = await context.core; + const soClient = coreContext.savedObjects.client; + const agentPolicy = await agentPolicyService.get(soClient, request.params.agentPolicyId); + + if (!agentPolicy) { + return response.customError({ + statusCode: 404, + body: { message: 'Agent policy not found' }, }); + } + const outputs = await agentPolicyService.getAllOutputsForPolicy(soClient, agentPolicy); - const outputsList = await agentPolicyService.listAllOutputsForPolicies(soClient, agentPolicies); + const body: GetAgentPolicyOutputsResponse = { + item: outputs, + }; + return response.ok({ + body, + }); +}; - const body: GetListAgentPolicyOutputsResponse = { - items: outputsList, - }; +export const GetListAgentPolicyOutputsHandler: FleetRequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const soClient = coreContext.savedObjects.client; + const { ids } = request.body; + if (!ids) { return response.ok({ - body, + body: { items: [] }, }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); } + const agentPolicies = await agentPolicyService.getByIDs(soClient, ids, { + withPackagePolicies: true, + }); + + const outputsList = await agentPolicyService.listAllOutputsForPolicies(soClient, agentPolicies); + + const body: GetListAgentPolicyOutputsResponse = { + items: outputsList, + }; + + return response.ok({ + body, + }); }; diff --git a/x-pack/plugins/fleet/server/routes/app/index.ts b/x-pack/plugins/fleet/server/routes/app/index.ts index db7eddd5ddd45..cb2eb0af1d154 100644 --- a/x-pack/plugins/fleet/server/routes/app/index.ts +++ b/x-pack/plugins/fleet/server/routes/app/index.ts @@ -15,7 +15,7 @@ import { APP_API_ROUTES } from '../../constants'; import { API_VERSIONS } from '../../../common/constants'; import { appContextService } from '../../services'; import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../../common/types'; -import { defaultFleetErrorHandler, GenerateServiceTokenError } from '../../errors'; +import { GenerateServiceTokenError } from '../../errors'; import type { FleetRequestHandler } from '../../types'; import { CheckPermissionsRequestSchema, CheckPermissionsResponseSchema } from '../../types'; import { enableSpaceAwarenessMigration } from '../../services/spaces/enable_space_awareness'; @@ -106,16 +106,11 @@ export const postEnableSpaceAwarenessHandler: FleetRequestHandler = async ( request, response ) => { - try { - await enableSpaceAwarenessMigration(); + await enableSpaceAwarenessMigration(); - return response.ok({ - body: {}, - }); - } catch (e) { - const error = new GenerateServiceTokenError(e); - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ + body: {}, + }); }; export const generateServiceTokenHandler: RequestHandler< @@ -145,11 +140,11 @@ export const generateServiceTokenHandler: RequestHandler< }); } else { const error = new GenerateServiceTokenError('Unable to generate service token'); - return defaultFleetErrorHandler({ error, response }); + throw error; } } catch (e) { const error = new GenerateServiceTokenError(e); - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -158,28 +153,24 @@ export const getAgentPoliciesSpacesHandler: FleetRequestHandler< null, TypeOf > = async (context, request, response) => { - try { - const spaces = await (await context.fleet).getAllSpaces(); - const security = appContextService.getSecurity(); - const spaceIds = spaces.map(({ id }) => id); - const res = await security.authz.checkPrivilegesWithRequest(request).atSpaces(spaceIds, { - kibana: [security.authz.actions.api.get(`fleet-agent-policies-all`)], - }); + const spaces = await (await context.fleet).getAllSpaces(); + const security = appContextService.getSecurity(); + const spaceIds = spaces.map(({ id }) => id); + const res = await security.authz.checkPrivilegesWithRequest(request).atSpaces(spaceIds, { + kibana: [security.authz.actions.api.get(`fleet-agent-policies-all`)], + }); - const authorizedSpaces = spaces.filter( - (space) => - res.privileges.kibana.find((privilege) => privilege.resource === space.id)?.authorized ?? - false - ); + const authorizedSpaces = spaces.filter( + (space) => + res.privileges.kibana.find((privilege) => privilege.resource === space.id)?.authorized ?? + false + ); - return response.ok({ - body: { - items: authorizedSpaces, - }, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ + body: { + items: authorizedSpaces, + }, + }); }; export const GenerateServiceTokenRequestSchema = { diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index 7cbc9d9274032..7ed4b5bacf336 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -8,19 +8,18 @@ import type { Dictionary } from 'lodash'; import { keyBy, keys, merge } from 'lodash'; import type { RequestHandler } from '@kbn/core/server'; import pMap from 'p-map'; +import type { IndicesDataStreamsStatsDataStreamsStatsItem } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ByteSizeValue } from '@kbn/config-schema'; import type { DataStream } from '../../types'; import { KibanaSavedObjectType } from '../../../common/types'; import type { GetDataStreamsResponse } from '../../../common/types'; import { getPackageSavedObjects } from '../../services/epm/packages/get'; -import { defaultFleetErrorHandler } from '../../errors'; import type { MeteringStats } from '../../services/data_streams'; import { dataStreamService } from '../../services/data_streams'; +import { appContextService } from '../../services'; import { getDataStreamsQueryMetadata } from './get_data_streams_query_metadata'; -import type { IndicesDataStreamsStatsDataStreamsStatsItem } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ByteSizeValue } from '@kbn/config-schema'; -import { appContextService } from '../../services'; const MANAGED_BY = 'fleet'; const LEGACY_MANAGED_BY = 'ingest-manager'; @@ -55,158 +54,154 @@ export const getListHandler: RequestHandler = async (context, request, response) data_streams: [], }; - try { - const useMeteringApi = appContextService.getConfig()?.internal?.useMeteringApi; - - // Get matching data streams, their stats, and package SOs - const [ - dataStreamsInfo, - dataStreamStatsOrUndefined, - dataStreamMeteringStatsorUndefined, - packageSavedObjects, - ] = await Promise.all([ - dataStreamService.getAllFleetDataStreams(esClient), - useMeteringApi - ? undefined - : dataStreamService.getAllFleetDataStreamsStats(elasticsearch.client.asSecondaryAuthUser), - useMeteringApi - ? dataStreamService.getAllFleetMeteringStats(elasticsearch.client.asSecondaryAuthUser) - : undefined, - getPackageSavedObjects(savedObjects.client), - ]); - - // managed_by property 'ingest-manager' added to allow for legacy data streams to be displayed - // See https://github.com/elastic/elastic-agent/issues/654 - - const filteredDataStreamsInfo = dataStreamsInfo.filter( - (ds) => ds?._meta?.managed_by === MANAGED_BY || ds?._meta?.managed_by === LEGACY_MANAGED_BY + const useMeteringApi = appContextService.getConfig()?.internal?.useMeteringApi; + + // Get matching data streams, their stats, and package SOs + const [ + dataStreamsInfo, + dataStreamStatsOrUndefined, + dataStreamMeteringStatsorUndefined, + packageSavedObjects, + ] = await Promise.all([ + dataStreamService.getAllFleetDataStreams(esClient), + useMeteringApi + ? undefined + : dataStreamService.getAllFleetDataStreamsStats(elasticsearch.client.asSecondaryAuthUser), + useMeteringApi + ? dataStreamService.getAllFleetMeteringStats(elasticsearch.client.asSecondaryAuthUser) + : undefined, + getPackageSavedObjects(savedObjects.client), + ]); + + // managed_by property 'ingest-manager' added to allow for legacy data streams to be displayed + // See https://github.com/elastic/elastic-agent/issues/654 + + const filteredDataStreamsInfo = dataStreamsInfo.filter( + (ds) => ds?._meta?.managed_by === MANAGED_BY || ds?._meta?.managed_by === LEGACY_MANAGED_BY + ); + + const dataStreamsInfoByName = keyBy(filteredDataStreamsInfo, 'name'); + + let dataStreamsStatsByName: Dictionary = {}; + if (dataStreamStatsOrUndefined) { + const filteredDataStreamsStats = dataStreamStatsOrUndefined.filter( + (dss) => !!dataStreamsInfoByName[dss.data_stream] ); + dataStreamsStatsByName = keyBy(filteredDataStreamsStats, 'data_stream'); + } + let dataStreamsMeteringStatsByName: Dictionary = {}; + if (dataStreamMeteringStatsorUndefined) { + dataStreamsMeteringStatsByName = keyBy(dataStreamMeteringStatsorUndefined, 'name'); + } - const dataStreamsInfoByName = keyBy(filteredDataStreamsInfo, 'name'); - - let dataStreamsStatsByName: Dictionary = {}; - if (dataStreamStatsOrUndefined) { - const filteredDataStreamsStats = dataStreamStatsOrUndefined.filter( - (dss) => !!dataStreamsInfoByName[dss.data_stream] - ); - dataStreamsStatsByName = keyBy(filteredDataStreamsStats, 'data_stream'); - } - let dataStreamsMeteringStatsByName: Dictionary = {}; - if (dataStreamMeteringStatsorUndefined) { - dataStreamsMeteringStatsByName = keyBy(dataStreamMeteringStatsorUndefined, 'name'); - } - - // Combine data stream info - const dataStreams = merge( - dataStreamsInfoByName, - dataStreamsStatsByName, - dataStreamsMeteringStatsByName - ); - const dataStreamNames = keys(dataStreams); - - // Map package SOs - const packageSavedObjectsByName = keyBy(packageSavedObjects.saved_objects, 'id'); - const packageMetadata: any = {}; - - // Get dashboard information for all packages - const dashboardIdsByPackageName = packageSavedObjects.saved_objects.reduce< - Record - >((allDashboards, pkgSavedObject) => { - const dashboards: string[] = []; - (pkgSavedObject.attributes?.installed_kibana || []).forEach((o) => { - if (o.type === KibanaSavedObjectType.dashboard) { - dashboards.push(o.id); - } - }); - allDashboards[pkgSavedObject.id] = dashboards; - return allDashboards; - }, {}); - const allDashboardSavedObjectsResponse = await savedObjects.client.bulkGet<{ - title?: string; - }>( - Object.values(dashboardIdsByPackageName).flatMap((dashboardIds) => - dashboardIds.map((id) => ({ - id, - type: KibanaSavedObjectType.dashboard, - fields: ['title'], - })) - ) - ); - // Ignore dashboards not found - const allDashboardSavedObjects = allDashboardSavedObjectsResponse.saved_objects.filter((so) => { - if (so.error) { - if (so.error.statusCode === 404) { - return false; - } - throw so.error; + // Combine data stream info + const dataStreams = merge( + dataStreamsInfoByName, + dataStreamsStatsByName, + dataStreamsMeteringStatsByName + ); + const dataStreamNames = keys(dataStreams); + + // Map package SOs + const packageSavedObjectsByName = keyBy(packageSavedObjects.saved_objects, 'id'); + const packageMetadata: any = {}; + + // Get dashboard information for all packages + const dashboardIdsByPackageName = packageSavedObjects.saved_objects.reduce< + Record + >((allDashboards, pkgSavedObject) => { + const dashboards: string[] = []; + (pkgSavedObject.attributes?.installed_kibana || []).forEach((o) => { + if (o.type === KibanaSavedObjectType.dashboard) { + dashboards.push(o.id); } - return true; }); + allDashboards[pkgSavedObject.id] = dashboards; + return allDashboards; + }, {}); + const allDashboardSavedObjectsResponse = await savedObjects.client.bulkGet<{ + title?: string; + }>( + Object.values(dashboardIdsByPackageName).flatMap((dashboardIds) => + dashboardIds.map((id) => ({ + id, + type: KibanaSavedObjectType.dashboard, + fields: ['title'], + })) + ) + ); + // Ignore dashboards not found + const allDashboardSavedObjects = allDashboardSavedObjectsResponse.saved_objects.filter((so) => { + if (so.error) { + if (so.error.statusCode === 404) { + return false; + } + throw so.error; + } + return true; + }); + + const allDashboardSavedObjectsById = keyBy( + allDashboardSavedObjects, + (dashboardSavedObject) => dashboardSavedObject.id + ); + + // Query additional information for each data stream + const queryDataStreamInfo = async (dataStreamName: string) => { + const dataStream = dataStreams[dataStreamName]; + + const dataStreamResponse: DataStream = { + index: dataStreamName, + dataset: '', + namespace: '', + type: '', + package: dataStream._meta?.package?.name || '', + package_version: '', + last_activity_ms: dataStream.maximum_timestamp, // overridden below if maxIngestedTimestamp agg returns a result + size_in_bytes: dataStream.store_size_bytes || dataStream.size_in_bytes, + // `store_size` should be available from ES due to ?human=true flag + // but fallback to bytes just in case + size_in_bytes_formatted: + dataStream.store_size || + new ByteSizeValue(dataStream.store_size_bytes || dataStream.size_in_bytes || 0).toString(), + dashboards: [], + serviceDetails: null, + }; - const allDashboardSavedObjectsById = keyBy( - allDashboardSavedObjects, - (dashboardSavedObject) => dashboardSavedObject.id - ); - - // Query additional information for each data stream - const queryDataStreamInfo = async (dataStreamName: string) => { - const dataStream = dataStreams[dataStreamName]; - - const dataStreamResponse: DataStream = { - index: dataStreamName, - dataset: '', - namespace: '', - type: '', - package: dataStream._meta?.package?.name || '', - package_version: '', - last_activity_ms: dataStream.maximum_timestamp, // overridden below if maxIngestedTimestamp agg returns a result - size_in_bytes: dataStream.store_size_bytes || dataStream.size_in_bytes, - // `store_size` should be available from ES due to ?human=true flag - // but fallback to bytes just in case - size_in_bytes_formatted: - dataStream.store_size || - new ByteSizeValue( - dataStream.store_size_bytes || dataStream.size_in_bytes || 0 - ).toString(), - dashboards: [], - serviceDetails: null, - }; - - const { maxIngested, namespace, dataset, type, serviceNames, environments } = - await getDataStreamsQueryMetadata({ dataStreamName: dataStream.name, esClient }); + const { maxIngested, namespace, dataset, type, serviceNames, environments } = + await getDataStreamsQueryMetadata({ dataStreamName: dataStream.name, esClient }); - // some integrations e.g custom logs don't have event.ingested - if (maxIngested) { - dataStreamResponse.last_activity_ms = maxIngested; - } + // some integrations e.g custom logs don't have event.ingested + if (maxIngested) { + dataStreamResponse.last_activity_ms = maxIngested; + } - if (serviceNames?.length === 1) { - const serviceDetails = { - serviceName: serviceNames[0], - environment: environments?.length === 1 ? environments[0] : 'ENVIRONMENT_ALL', - }; - dataStreamResponse.serviceDetails = serviceDetails; - } + if (serviceNames?.length === 1) { + const serviceDetails = { + serviceName: serviceNames[0], + environment: environments?.length === 1 ? environments[0] : 'ENVIRONMENT_ALL', + }; + dataStreamResponse.serviceDetails = serviceDetails; + } - dataStreamResponse.dataset = dataset; - dataStreamResponse.namespace = namespace; - dataStreamResponse.type = type; - - // Find package saved object - const pkgName = dataStreamResponse.package; - const pkgSavedObject = pkgName ? packageSavedObjectsByName[pkgName] : null; - - if (pkgSavedObject) { - // if - // - the data stream is associated with a package - // - and the package has been installed through EPM - // - and we didn't pick the metadata in an earlier iteration of this map() - if (!packageMetadata[pkgName]) { - // then pick the dashboards from the package saved object - const packageDashboardIds = dashboardIdsByPackageName[pkgName] || []; - const packageDashboards = packageDashboardIds.reduce< - Array<{ id: string; title: string }> - >((dashboards, dashboardId) => { + dataStreamResponse.dataset = dataset; + dataStreamResponse.namespace = namespace; + dataStreamResponse.type = type; + + // Find package saved object + const pkgName = dataStreamResponse.package; + const pkgSavedObject = pkgName ? packageSavedObjectsByName[pkgName] : null; + + if (pkgSavedObject) { + // if + // - the data stream is associated with a package + // - and the package has been installed through EPM + // - and we didn't pick the metadata in an earlier iteration of this map() + if (!packageMetadata[pkgName]) { + // then pick the dashboards from the package saved object + const packageDashboardIds = dashboardIdsByPackageName[pkgName] || []; + const packageDashboards = packageDashboardIds.reduce>( + (dashboards, dashboardId) => { const dashboard = allDashboardSavedObjectsById[dashboardId]; if (dashboard) { dashboards.push({ @@ -215,37 +210,36 @@ export const getListHandler: RequestHandler = async (context, request, response) }); } return dashboards; - }, []); - - packageMetadata[pkgName] = { - version: pkgSavedObject.attributes?.version || '', - dashboards: packageDashboards, - }; - } - - // Set values from package information - dataStreamResponse.package = pkgName; - dataStreamResponse.package_version = packageMetadata[pkgName].version; - dataStreamResponse.dashboards = packageMetadata[pkgName].dashboards; + }, + [] + ); + + packageMetadata[pkgName] = { + version: pkgSavedObject.attributes?.version || '', + dashboards: packageDashboards, + }; } - return dataStreamResponse; - }; + // Set values from package information + dataStreamResponse.package = pkgName; + dataStreamResponse.package_version = packageMetadata[pkgName].version; + dataStreamResponse.dashboards = packageMetadata[pkgName].dashboards; + } - // Return final data streams objects sorted by last activity, descending - // After filtering out data streams that are missing dataset/namespace/type/package fields - body.data_streams = ( - await pMap(dataStreamNames, (dataStreamName) => queryDataStreamInfo(dataStreamName), { - concurrency: 50, - }) - ) - .filter(({ dataset, namespace, type }) => dataset && namespace && type) - .sort((a, b) => b.last_activity_ms - a.last_activity_ms); + return dataStreamResponse; + }; - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + // Return final data streams objects sorted by last activity, descending + // After filtering out data streams that are missing dataset/namespace/type/package fields + body.data_streams = ( + await pMap(dataStreamNames, (dataStreamName) => queryDataStreamInfo(dataStreamName), { + concurrency: 50, + }) + ) + .filter(({ dataset, namespace, type }) => dataset && namespace && type) + .sort((a, b) => b.last_activity_ms - a.last_activity_ms); + + return response.ok({ + body, + }); }; diff --git a/x-pack/plugins/fleet/server/routes/download_source/handler.ts b/x-pack/plugins/fleet/server/routes/download_source/handler.ts index 8807106de441e..0b2254d85f867 100644 --- a/x-pack/plugins/fleet/server/routes/download_source/handler.ts +++ b/x-pack/plugins/fleet/server/routes/download_source/handler.ts @@ -21,25 +21,20 @@ import type { GetDownloadSourceResponse, } from '../../../common/types'; import { downloadSourceService } from '../../services/download_source'; -import { defaultFleetErrorHandler } from '../../errors'; import { agentPolicyService } from '../../services'; export const getDownloadSourcesHandler: RequestHandler = async (context, request, response) => { const soClient = (await context.core).savedObjects.client; - try { - const downloadSources = await downloadSourceService.list(soClient); + const downloadSources = await downloadSourceService.list(soClient); - const body: GetDownloadSourceResponse = { - items: downloadSources.items, - page: downloadSources.page, - perPage: downloadSources.perPage, - total: downloadSources.total, - }; + const body: GetDownloadSourceResponse = { + items: downloadSources.items, + page: downloadSources.page, + perPage: downloadSources.perPage, + total: downloadSources.total, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getOneDownloadSourcesHandler: RequestHandler< @@ -61,7 +56,7 @@ export const getOneDownloadSourcesHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -94,7 +89,7 @@ export const putDownloadSourcesHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -106,21 +101,17 @@ export const postDownloadSourcesHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const { id, ...data } = request.body; - const downloadSource = await downloadSourceService.create(soClient, data, { id }); - if (downloadSource.is_default) { - await agentPolicyService.bumpAllAgentPolicies(esClient); - } + const { id, ...data } = request.body; + const downloadSource = await downloadSourceService.create(soClient, data, { id }); + if (downloadSource.is_default) { + await agentPolicyService.bumpAllAgentPolicies(esClient); + } - const body: GetOneDownloadSourceResponse = { - item: downloadSource, - }; + const body: GetOneDownloadSourceResponse = { + item: downloadSource, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const deleteDownloadSourcesHandler: RequestHandler< @@ -142,6 +133,6 @@ export const deleteDownloadSourcesHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts index a38f5bdadc617..5f225fc5da709 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts @@ -22,7 +22,7 @@ import type { } from '../../../common/types'; import * as APIKeyService from '../../services/api_keys'; import { agentPolicyService } from '../../services/agent_policy'; -import { defaultFleetErrorHandler, AgentPolicyNotFoundError } from '../../errors'; +import { AgentPolicyNotFoundError } from '../../errors'; import { getCurrentNamespace } from '../../services/spaces/get_current_namespace'; import { isSpaceAwarenessEnabled } from '../../services/spaces/helpers'; @@ -34,26 +34,22 @@ export const getEnrollmentApiKeysHandler: RequestHandler< const esClient = (await context.core).elasticsearch.client.asInternalUser; const soClient = (await context.core).savedObjects.client; - try { - const useSpaceAwareness = await isSpaceAwarenessEnabled(); - const { items, total, page, perPage } = await APIKeyService.listEnrollmentApiKeys(esClient, { - page: request.query.page, - perPage: request.query.perPage, - kuery: request.query.kuery, - spaceId: useSpaceAwareness ? getCurrentNamespace(soClient) : undefined, - }); - const body: GetEnrollmentAPIKeysResponse = { - list: items, // deprecated - items, - total, - page, - perPage, - }; - - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const useSpaceAwareness = await isSpaceAwarenessEnabled(); + const { items, total, page, perPage } = await APIKeyService.listEnrollmentApiKeys(esClient, { + page: request.query.page, + perPage: request.query.perPage, + kuery: request.query.kuery, + spaceId: useSpaceAwareness ? getCurrentNamespace(soClient) : undefined, + }); + const body: GetEnrollmentAPIKeysResponse = { + list: items, // deprecated + items, + total, + page, + perPage, + }; + + return response.ok({ body }); }; export const postEnrollmentApiKeyHandler: RequestHandler< undefined, @@ -63,28 +59,24 @@ export const postEnrollmentApiKeyHandler: RequestHandler< const { elasticsearch, savedObjects } = await context.core; const soClient = savedObjects.client; const esClient = elasticsearch.client.asInternalUser; - try { - // validate policy exists in the current space - await agentPolicyService.get(soClient, request.body.policy_id).catch((err) => { - if (SavedObjectsErrorHelpers.isNotFoundError(err)) { - throw new AgentPolicyNotFoundError(`Agent policy "${request.body.policy_id}" not found`); - } + // validate policy exists in the current space + await agentPolicyService.get(soClient, request.body.policy_id).catch((err) => { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + throw new AgentPolicyNotFoundError(`Agent policy "${request.body.policy_id}" not found`); + } - throw err; - }); + throw err; + }); - const apiKey = await APIKeyService.generateEnrollmentAPIKey(soClient, esClient, { - name: request.body.name, - expiration: request.body.expiration, - agentPolicyId: request.body.policy_id, - }); + const apiKey = await APIKeyService.generateEnrollmentAPIKey(soClient, esClient, { + name: request.body.name, + expiration: request.body.expiration, + agentPolicyId: request.body.policy_id, + }); - const body: PostEnrollmentAPIKeyResponse = { item: apiKey, action: 'created' }; + const body: PostEnrollmentAPIKeyResponse = { item: apiKey, action: 'created' }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const deleteEnrollmentApiKeyHandler: RequestHandler< @@ -111,7 +103,7 @@ export const deleteEnrollmentApiKeyHandler: RequestHandler< body: { message: `EnrollmentAPIKey ${request.params.keyId} not found` }, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -141,6 +133,6 @@ export const getOneEnrollmentApiKeyHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; diff --git a/x-pack/plugins/fleet/server/routes/epm/file_handler.ts b/x-pack/plugins/fleet/server/routes/epm/file_handler.ts index 994f52a71c224..701915d384f47 100644 --- a/x-pack/plugins/fleet/server/routes/epm/file_handler.ts +++ b/x-pack/plugins/fleet/server/routes/epm/file_handler.ts @@ -13,7 +13,6 @@ import type { ResponseHeaders, KnownHeaders, HttpResponseOptions } from '@kbn/co import type { GetFileRequestSchema, FleetRequestHandler } from '../../types'; import { getFile, getInstallation } from '../../services/epm/packages'; -import { defaultFleetErrorHandler } from '../../errors'; import { getAsset } from '../../services/epm/archive/storage'; import { getBundledPackageByPkgKey } from '../../services/epm/packages/bundled_packages'; import { pkgToPkgKey } from '../../services/epm/registry'; @@ -25,104 +24,100 @@ const CACHE_CONTROL_10_MINUTES_HEADER: HttpResponseOptions['headers'] = { export const getFileHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { - try { - const { pkgName, pkgVersion, filePath } = request.params; - const savedObjectsClient = (await context.fleet).internalSoClient; - - const installation = await getInstallation({ savedObjectsClient, pkgName }); - const isPackageInstalled = pkgVersion === installation?.version; - const assetPath = `${pkgName}-${pkgVersion}/${filePath}`; - - if (isPackageInstalled) { - const storedAsset = await getAsset({ savedObjectsClient, path: assetPath }); - - if (!storedAsset) { - return response.custom({ - body: `installed package file not found: ${filePath}`, - statusCode: 404, - }); - } + const { pkgName, pkgVersion, filePath } = request.params; + const savedObjectsClient = (await context.fleet).internalSoClient; - const contentType = storedAsset.media_type; - const buffer = storedAsset.data_utf8 - ? Buffer.from(storedAsset.data_utf8, 'utf8') - : Buffer.from(storedAsset.data_base64, 'base64'); + const installation = await getInstallation({ savedObjectsClient, pkgName }); + const isPackageInstalled = pkgVersion === installation?.version; + const assetPath = `${pkgName}-${pkgVersion}/${filePath}`; - if (!contentType) { - return response.custom({ - body: `unknown content type for file: ${filePath}`, - statusCode: 400, - }); - } + if (isPackageInstalled) { + const storedAsset = await getAsset({ savedObjectsClient, path: assetPath }); + + if (!storedAsset) { + return response.custom({ + body: `installed package file not found: ${filePath}`, + statusCode: 404, + }); + } + const contentType = storedAsset.media_type; + const buffer = storedAsset.data_utf8 + ? Buffer.from(storedAsset.data_utf8, 'utf8') + : Buffer.from(storedAsset.data_base64, 'base64'); + + if (!contentType) { return response.custom({ - body: buffer, - statusCode: 200, - headers: { - ...CACHE_CONTROL_10_MINUTES_HEADER, - 'content-type': contentType, - }, + body: `unknown content type for file: ${filePath}`, + statusCode: 400, }); } - const bundledPackage = await getBundledPackageByPkgKey( - pkgToPkgKey({ name: pkgName, version: pkgVersion }) + return response.custom({ + body: buffer, + statusCode: 200, + headers: { + ...CACHE_CONTROL_10_MINUTES_HEADER, + 'content-type': contentType, + }, + }); + } + + const bundledPackage = await getBundledPackageByPkgKey( + pkgToPkgKey({ name: pkgName, version: pkgVersion }) + ); + if (bundledPackage) { + const bufferEntries = await unpackArchiveEntriesIntoMemory( + await bundledPackage.getBuffer(), + 'application/zip' ); - if (bundledPackage) { - const bufferEntries = await unpackArchiveEntriesIntoMemory( - await bundledPackage.getBuffer(), - 'application/zip' - ); - - const fileBuffer = bufferEntries.find((entry) => entry.path === assetPath)?.buffer; - - if (!fileBuffer) { - return response.custom({ - body: `bundled package file not found: ${filePath}`, - statusCode: 404, - }); - } - // if storedAsset is not available, fileBuffer *must* be - // b/c we error if we don't have at least one, and storedAsset is the least likely - const { buffer, contentType } = { - contentType: mime.contentType(path.extname(assetPath)), - buffer: fileBuffer, - }; - - if (!contentType) { - return response.custom({ - body: `unknown content type for file: ${filePath}`, - statusCode: 400, - }); - } + const fileBuffer = bufferEntries.find((entry) => entry.path === assetPath)?.buffer; + if (!fileBuffer) { return response.custom({ - body: buffer, - statusCode: 200, - headers: { - ...CACHE_CONTROL_10_MINUTES_HEADER, - 'content-type': contentType, - }, + body: `bundled package file not found: ${filePath}`, + statusCode: 404, }); - } else { - const registryResponse = await getFile(pkgName, pkgVersion, filePath); - const headersToProxy: KnownHeaders[] = ['content-type']; - const proxiedHeaders = headersToProxy.reduce((headers, knownHeader) => { - const value = registryResponse.headers.get(knownHeader); - if (value !== null) { - headers[knownHeader] = value; - } - return headers; - }, {} as ResponseHeaders); + } + // if storedAsset is not available, fileBuffer *must* be + // b/c we error if we don't have at least one, and storedAsset is the least likely + const { buffer, contentType } = { + contentType: mime.contentType(path.extname(assetPath)), + buffer: fileBuffer, + }; + + if (!contentType) { return response.custom({ - body: registryResponse.body, - statusCode: registryResponse.status, - headers: { ...CACHE_CONTROL_10_MINUTES_HEADER, ...proxiedHeaders }, + body: `unknown content type for file: ${filePath}`, + statusCode: 400, }); } - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + + return response.custom({ + body: buffer, + statusCode: 200, + headers: { + ...CACHE_CONTROL_10_MINUTES_HEADER, + 'content-type': contentType, + }, + }); + } else { + const registryResponse = await getFile(pkgName, pkgVersion, filePath); + const headersToProxy: KnownHeaders[] = ['content-type']; + const proxiedHeaders = headersToProxy.reduce((headers, knownHeader) => { + const value = registryResponse.headers.get(knownHeader); + if (value !== null) { + headers[knownHeader] = value; + } + return headers; + }, {} as ResponseHeaders); + + return response.custom({ + body: registryResponse.body, + statusCode: registryResponse.status, + headers: { ...CACHE_CONTROL_10_MINUTES_HEADER, ...proxiedHeaders }, + }); } }; diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 1fcd0e26a6ef0..2b4fa98e38f6b 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -67,12 +67,7 @@ import { getTemplateInputs, } from '../../services/epm/packages'; import type { BulkInstallResponse } from '../../services/epm/packages'; -import { - defaultFleetErrorHandler, - fleetErrorToResponseOptions, - FleetError, - FleetTooManyRequestsError, -} from '../../errors'; +import { fleetErrorToResponseOptions, FleetError, FleetTooManyRequestsError } from '../../errors'; import { appContextService, checkAllowedPackages, packagePolicyService } from '../../services'; import { getPackageUsageStats } from '../../services/epm/packages/get'; import { updatePackage } from '../../services/epm/packages/update'; @@ -97,91 +92,75 @@ export const getCategoriesHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const items = await getCategories({ - ...request.query, - }); - const body: GetCategoriesResponse = { - items, - }; - return response.ok({ body, headers: { ...CACHE_CONTROL_10_MINUTES_HEADER } }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const items = await getCategories({ + ...request.query, + }); + const body: GetCategoriesResponse = { + items, + }; + return response.ok({ body, headers: { ...CACHE_CONTROL_10_MINUTES_HEADER } }); }; export const getListHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const savedObjectsClient = (await context.fleet).internalSoClient; - const res = await getPackages({ - savedObjectsClient, - ...request.query, - }); - const flattenedRes = res.map((pkg) => soToInstallationInfo(pkg)) as PackageList; - const body: GetPackagesResponse = { - items: flattenedRes, - }; - return response.ok({ - body, - // Only cache responses where the installation status is excluded, otherwise the request - // needs up-to-date information on whether the package is installed so we can't cache it - headers: request.query.excludeInstallStatus ? { ...CACHE_CONTROL_10_MINUTES_HEADER } : {}, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const savedObjectsClient = (await context.fleet).internalSoClient; + const res = await getPackages({ + savedObjectsClient, + ...request.query, + }); + const flattenedRes = res.map((pkg) => soToInstallationInfo(pkg)) as PackageList; + const body: GetPackagesResponse = { + items: flattenedRes, + }; + return response.ok({ + body, + // Only cache responses where the installation status is excluded, otherwise the request + // needs up-to-date information on whether the package is installed so we can't cache it + headers: request.query.excludeInstallStatus ? { ...CACHE_CONTROL_10_MINUTES_HEADER } : {}, + }); }; export const getInstalledListHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const [fleetContext, coreContext] = await Promise.all([context.fleet, context.core]); - const savedObjectsClient = fleetContext.internalSoClient; - const esClient = coreContext.elasticsearch.client.asCurrentUser; - const res = await getInstalledPackages({ - savedObjectsClient, - esClient, - ...request.query, - }); + const [fleetContext, coreContext] = await Promise.all([context.fleet, context.core]); + const savedObjectsClient = fleetContext.internalSoClient; + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const res = await getInstalledPackages({ + savedObjectsClient, + esClient, + ...request.query, + }); - const body: GetInstalledPackagesResponse = { ...res }; + const body: GetInstalledPackagesResponse = { ...res }; - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ + body, + }); }; export const getDataStreamsHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const coreContext = await context.core; - // Query datastreams as the current user as the Kibana internal user may not have all the required permissions - const esClient = coreContext.elasticsearch.client.asCurrentUser; - const res = await getDataStreams({ - esClient, - ...request.query, - }); + const coreContext = await context.core; + // Query datastreams as the current user as the Kibana internal user may not have all the required permissions + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const res = await getDataStreams({ + esClient, + ...request.query, + }); - const body: GetEpmDataStreamsResponse = { - ...res, - }; + const body: GetEpmDataStreamsResponse = { + ...res, + }; - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ + body, + }); }; export const getLimitedListHandler: FleetRequestHandler< @@ -189,69 +168,61 @@ export const getLimitedListHandler: FleetRequestHandler< TypeOf, undefined > = async (context, request, response) => { - try { - const savedObjectsClient = (await context.fleet).internalSoClient; - const res = await getLimitedPackages({ - savedObjectsClient, - prerelease: request.query.prerelease, - }); - const body: GetLimitedPackagesResponse = { - items: res, - }; - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const savedObjectsClient = (await context.fleet).internalSoClient; + const res = await getLimitedPackages({ + savedObjectsClient, + prerelease: request.query.prerelease, + }); + const body: GetLimitedPackagesResponse = { + items: res, + }; + return response.ok({ + body, + }); }; export const getInfoHandler: FleetRequestHandler< TypeOf, TypeOf > = async (context, request, response) => { - try { - const savedObjectsClient = (await context.fleet).internalSoClient; - const { limitedToPackages } = await context.fleet; - const { pkgName, pkgVersion } = request.params; + const savedObjectsClient = (await context.fleet).internalSoClient; + const { limitedToPackages } = await context.fleet; + const { pkgName, pkgVersion } = request.params; - checkAllowedPackages([pkgName], limitedToPackages); + checkAllowedPackages([pkgName], limitedToPackages); - const { ignoreUnverified = false, full = false, prerelease } = request.query; - if (pkgVersion && !semverValid(pkgVersion)) { - throw new FleetError('Package version is not a valid semver'); - } - const res = await getPackageInfo({ - savedObjectsClient, - pkgName, - pkgVersion: pkgVersion || '', - skipArchive: !full, - ignoreUnverified, - prerelease, + const { ignoreUnverified = false, full = false, prerelease } = request.query; + if (pkgVersion && !semverValid(pkgVersion)) { + throw new FleetError('Package version is not a valid semver'); + } + const res = await getPackageInfo({ + savedObjectsClient, + pkgName, + pkgVersion: pkgVersion || '', + skipArchive: !full, + ignoreUnverified, + prerelease, + }); + const flattenedRes = soToInstallationInfo(res) as PackageInfo; + let metadata: any; + if (request.query.withMetadata) { + const allSpaceSoClient = appContextService.getInternalUserSOClientWithoutSpaceExtension(); + const { total } = await packagePolicyService.list(allSpaceSoClient, { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, + page: 1, + perPage: 0, + spaceId: '*', }); - const flattenedRes = soToInstallationInfo(res) as PackageInfo; - let metadata: any; - if (request.query.withMetadata) { - const allSpaceSoClient = appContextService.getInternalUserSOClientWithoutSpaceExtension(); - const { total } = await packagePolicyService.list(allSpaceSoClient, { - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, - page: 1, - perPage: 0, - spaceId: '*', - }); - metadata = { - has_policies: total > 0, - }; - } - - const body: GetInfoResponse = { - item: flattenedRes, - metadata, + metadata = { + has_policies: total > 0, }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); } + + const body: GetInfoResponse = { + item: flattenedRes, + metadata, + }; + return response.ok({ body }); }; export const getBulkAssetsHandler: FleetRequestHandler< @@ -260,23 +231,19 @@ export const getBulkAssetsHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { const coreContext = await context.core; - try { - const { assetIds } = request.body; - const savedObjectsClient = coreContext.savedObjects.client; - const savedObjectsTypeRegistry = coreContext.savedObjects.typeRegistry; - const assets = await getBulkAssets( - savedObjectsClient, - savedObjectsTypeRegistry, - assetIds as AssetSOObject[] - ); + const { assetIds } = request.body; + const savedObjectsClient = coreContext.savedObjects.client; + const savedObjectsTypeRegistry = coreContext.savedObjects.typeRegistry; + const assets = await getBulkAssets( + savedObjectsClient, + savedObjectsTypeRegistry, + assetIds as AssetSOObject[] + ); - const body: GetBulkAssetsResponse = { - items: assets, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const body: GetBulkAssetsResponse = { + items: assets, + }; + return response.ok({ body }); }; export const updatePackageHandler: FleetRequestHandler< @@ -284,34 +251,26 @@ export const updatePackageHandler: FleetRequestHandler< unknown, TypeOf > = async (context, request, response) => { - try { - const savedObjectsClient = (await context.fleet).internalSoClient; - const { pkgName } = request.params; + const savedObjectsClient = (await context.fleet).internalSoClient; + const { pkgName } = request.params; - const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body }); - const body: UpdatePackageResponse = { - item: res, - }; + const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body }); + const body: UpdatePackageResponse = { + item: res, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getStatsHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { - try { - const { pkgName } = request.params; - const savedObjectsClient = (await context.fleet).internalSoClient; - const body: GetStatsResponse = { - response: await getPackageUsageStats({ savedObjectsClient, pkgName }), - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const { pkgName } = request.params; + const savedObjectsClient = (await context.fleet).internalSoClient; + const body: GetStatsResponse = { + response: await getPackageUsageStats({ savedObjectsClient, pkgName }), + }; + return response.ok({ body }); }; export const installPackageFromRegistryHandler: FleetRequestHandler< @@ -354,7 +313,7 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< }; return response.ok({ body }); } else { - return await defaultFleetErrorHandler({ error: res.error, response }); + throw res.error; } }; @@ -395,7 +354,7 @@ export const createCustomIntegrationHandler: FleetRequestHandler< }; return response.ok({ body }); } else { - return await defaultFleetErrorHandler({ error: res.error, response }); + throw res.error; } } catch (error) { if (error instanceof NamingCollisionError) { @@ -413,7 +372,7 @@ export const createCustomIntegrationHandler: FleetRequestHandler< }, }); } - return await defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -508,7 +467,7 @@ export const installPackageByUploadHandler: FleetRequestHandler< }, }); } - return defaultFleetErrorHandler({ error: res.error, response }); + throw res.error; } }; @@ -516,26 +475,22 @@ export const deletePackageHandler: FleetRequestHandler< TypeOf, TypeOf > = async (context, request, response) => { - try { - const { pkgName, pkgVersion } = request.params; - const coreContext = await context.core; - const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.internalSoClient; - const esClient = coreContext.elasticsearch.client.asInternalUser; - const res = await removeInstallation({ - savedObjectsClient, - pkgName, - pkgVersion, - esClient, - force: request.query?.force, - }); - const body: DeletePackageResponse = { - items: res, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const { pkgName, pkgVersion } = request.params; + const coreContext = await context.core; + const fleetContext = await context.fleet; + const savedObjectsClient = fleetContext.internalSoClient; + const esClient = coreContext.elasticsearch.client.asInternalUser; + const res = await removeInstallation({ + savedObjectsClient, + pkgName, + pkgVersion, + esClient, + force: request.query?.force, + }); + const body: DeletePackageResponse = { + items: res, + }; + return response.ok({ body }); }; export const getVerificationKeyIdHandler: FleetRequestHandler = async ( @@ -543,15 +498,11 @@ export const getVerificationKeyIdHandler: FleetRequestHandler = async ( request, response ) => { - try { - const packageVerificationKeyId = await getGpgKeyIdOrUndefined(); - const body: GetVerificationKeyIdResponse = { - id: packageVerificationKeyId || null, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const packageVerificationKeyId = await getGpgKeyIdOrUndefined(); + const body: GetVerificationKeyIdResponse = { + id: packageVerificationKeyId || null, + }; + return response.ok({ body }); }; /** @@ -583,33 +534,29 @@ export const reauthorizeTransformsHandler: FleetRequestHandler< // User might not have permission to get username, or security is not enabled, and that's okay. } - try { - const logger = appContextService.getLogger(); - const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, username); - const secondaryAuth = await generateTransformSecondaryAuthHeaders({ - authorizationHeader, + const logger = appContextService.getLogger(); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, username); + const secondaryAuth = await generateTransformSecondaryAuthHeaders({ + authorizationHeader, + logger, + username, + pkgName, + pkgVersion, + }); + + const resp: Array<{ transformId: string; success: boolean; error: null | any }> = + await handleTransformReauthorizeAndStart({ + esClient, + savedObjectsClient, logger, - username, pkgName, pkgVersion, + transforms, + secondaryAuth, + username, }); - const resp: Array<{ transformId: string; success: boolean; error: null | any }> = - await handleTransformReauthorizeAndStart({ - esClient, - savedObjectsClient, - logger, - pkgName, - pkgVersion, - transforms, - secondaryAuth, - username, - }); - - return response.ok({ body: resp }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: resp }); }; export const getInputsHandler: FleetRequestHandler< @@ -619,33 +566,29 @@ export const getInputsHandler: FleetRequestHandler< > = async (context, request, response) => { const soClient = (await context.fleet).internalSoClient; - try { - const { pkgName, pkgVersion } = request.params; - const { format, prerelease, ignoreUnverified } = request.query; - let body; - if (format === 'json') { - body = await getTemplateInputs( - soClient, - pkgName, - pkgVersion, - 'json', - prerelease, - ignoreUnverified - ); - } else if (format === 'yml' || format === 'yaml') { - body = await getTemplateInputs( - soClient, - pkgName, - pkgVersion, - 'yml', - prerelease, - ignoreUnverified - ); - } - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + const { pkgName, pkgVersion } = request.params; + const { format, prerelease, ignoreUnverified } = request.query; + let body; + if (format === 'json') { + body = await getTemplateInputs( + soClient, + pkgName, + pkgVersion, + 'json', + prerelease, + ignoreUnverified + ); + } else if (format === 'yml' || format === 'yaml') { + body = await getTemplateInputs( + soClient, + pkgName, + pkgVersion, + 'yml', + prerelease, + ignoreUnverified + ); } + return response.ok({ body }); }; // Don't expose the whole SO in the API response, only selected fields diff --git a/x-pack/plugins/fleet/server/routes/epm/kibana_assets_handler.ts b/x-pack/plugins/fleet/server/routes/epm/kibana_assets_handler.ts index ad0bec6397ee8..57880d5f08397 100644 --- a/x-pack/plugins/fleet/server/routes/epm/kibana_assets_handler.ts +++ b/x-pack/plugins/fleet/server/routes/epm/kibana_assets_handler.ts @@ -7,7 +7,7 @@ import type { TypeOf } from '@kbn/config-schema'; -import { defaultFleetErrorHandler, FleetNotFoundError } from '../../errors'; +import { FleetNotFoundError } from '../../errors'; import { appContextService } from '../../services'; import { deleteKibanaAssetsAndReferencesForSpace, @@ -29,87 +29,79 @@ export const installPackageKibanaAssetsHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.internalSoClient; - const logger = appContextService.getLogger(); - const spaceId = fleetContext.spaceId; - const { pkgName, pkgVersion } = request.params; + const fleetContext = await context.fleet; + const savedObjectsClient = fleetContext.internalSoClient; + const logger = appContextService.getLogger(); + const spaceId = fleetContext.spaceId; + const { pkgName, pkgVersion } = request.params; - const installedPkgWithAssets = await getInstalledPackageWithAssets({ - savedObjectsClient, - pkgName, - logger, - }); + const installedPkgWithAssets = await getInstalledPackageWithAssets({ + savedObjectsClient, + pkgName, + logger, + }); - const installation = await getInstallationObject({ - pkgName, - savedObjectsClient, - }); + const installation = await getInstallationObject({ + pkgName, + savedObjectsClient, + }); - if ( - !installation || - !installedPkgWithAssets || - installedPkgWithAssets?.installation.version !== pkgVersion - ) { - throw new FleetNotFoundError('Requested version is not installed'); - } + if ( + !installation || + !installedPkgWithAssets || + installedPkgWithAssets?.installation.version !== pkgVersion + ) { + throw new FleetNotFoundError('Requested version is not installed'); + } - const { packageInfo } = installedPkgWithAssets; + const { packageInfo } = installedPkgWithAssets; - await installKibanaAssetsAndReferences({ - savedObjectsClient, - logger, - pkgName, - pkgTitle: packageInfo.title, - installAsAdditionalSpace: true, - spaceId, - assetTags: installedPkgWithAssets.packageInfo?.asset_tags, - installedPkg: installation, - packageInstallContext: { - packageInfo, - paths: installedPkgWithAssets.paths, - assetsMap: installedPkgWithAssets.assetsMap, - archiveIterator: createArchiveIteratorFromMap(installedPkgWithAssets.assetsMap), - }, - }); + await installKibanaAssetsAndReferences({ + savedObjectsClient, + logger, + pkgName, + pkgTitle: packageInfo.title, + installAsAdditionalSpace: true, + spaceId, + assetTags: installedPkgWithAssets.packageInfo?.asset_tags, + installedPkg: installation, + packageInstallContext: { + packageInfo, + paths: installedPkgWithAssets.paths, + assetsMap: installedPkgWithAssets.assetsMap, + archiveIterator: createArchiveIteratorFromMap(installedPkgWithAssets.assetsMap), + }, + }); - return response.ok({ body: { success: true } }); - } catch (error) { - return await defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: { success: true } }); }; export const deletePackageKibanaAssetsHandler: FleetRequestHandler< TypeOf, undefined > = async (context, request, response) => { - try { - const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.internalSoClient; - const logger = appContextService.getLogger(); - const spaceId = fleetContext.spaceId; - const { pkgName, pkgVersion } = request.params; + const fleetContext = await context.fleet; + const savedObjectsClient = fleetContext.internalSoClient; + const logger = appContextService.getLogger(); + const spaceId = fleetContext.spaceId; + const { pkgName, pkgVersion } = request.params; - const installation = await getInstallationObject({ - pkgName, - savedObjectsClient, - }); + const installation = await getInstallationObject({ + pkgName, + savedObjectsClient, + }); - if (!installation || installation.attributes.version !== pkgVersion) { - throw new FleetNotFoundError('Version is not installed'); - } + if (!installation || installation.attributes.version !== pkgVersion) { + throw new FleetNotFoundError('Version is not installed'); + } - await deleteKibanaAssetsAndReferencesForSpace({ - savedObjectsClient, - logger, - pkgName, - spaceId, - installedPkg: installation, - }); + await deleteKibanaAssetsAndReferencesForSpace({ + savedObjectsClient, + logger, + pkgName, + spaceId, + installedPkg: installation, + }); - return response.ok({ body: { success: true } }); - } catch (error) { - return await defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body: { success: true } }); }; diff --git a/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts b/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts index 52e469971177d..2d63b357347db 100644 --- a/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts +++ b/x-pack/plugins/fleet/server/routes/fleet_proxies/handler.ts @@ -22,7 +22,6 @@ import { updateFleetProxy, getFleetProxyRelatedSavedObjects, } from '../../services/fleet_proxies'; -import { defaultFleetErrorHandler } from '../../errors'; import type { GetOneFleetProxyRequestSchema, PostFleetProxyRequestSchema, @@ -80,18 +79,14 @@ export const postFleetProxyHandler: RequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const soClient = coreContext.savedObjects.client; - try { - const { id, ...data } = request.body; - const proxy = await createFleetProxy(soClient, { ...data, is_preconfigured: false }, { id }); + const { id, ...data } = request.body; + const proxy = await createFleetProxy(soClient, { ...data, is_preconfigured: false }, { id }); - const body = { - item: proxy, - }; + const body = { + item: proxy, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const putFleetProxyHandler: RequestHandler< @@ -125,26 +120,22 @@ export const putFleetProxyHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; export const getAllFleetProxyHandler: RequestHandler = async (context, request, response) => { const soClient = (await context.core).savedObjects.client; - try { - const res = await listFleetProxies(soClient); - const body = { - items: res.items, - page: res.page, - perPage: res.perPage, - total: res.total, - }; + const res = await listFleetProxies(soClient); + const body = { + items: res.items, + page: res.page, + perPage: res.perPage, + total: res.total, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const deleteFleetProxyHandler: RequestHandler< @@ -177,7 +168,7 @@ export const deleteFleetProxyHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -199,6 +190,6 @@ export const getFleetProxyHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; diff --git a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts index bfcb91351af11..4ddcd47ad9ab4 100644 --- a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts +++ b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts @@ -8,9 +8,14 @@ import { SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID } from '../../constants'; import { agentPolicyService, appContextService } from '../../services'; import * as fleetServerService from '../../services/fleet_server_host'; +import { withDefaultErrorHandler } from '../../services/security/fleet_router'; import { postFleetServerHost, putFleetServerHostHandler } from './handler'; +const postFleetServerHostWithErrorHandler = withDefaultErrorHandler(postFleetServerHost); +const putFleetServerHostHandlerWithErrorHandler = + withDefaultErrorHandler(putFleetServerHostHandler); + describe('fleet server hosts handler', () => { const mockContext = { core: Promise.resolve({ @@ -45,7 +50,7 @@ describe('fleet server hosts handler', () => { it('should return error on post in serverless if host url is different from default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await postFleetServerHost( + const res = await postFleetServerHostWithErrorHandler( mockContext, { body: { id: 'host1', host_urls: ['http://localhost:8080'] } } as any, mockResponse as any @@ -62,7 +67,7 @@ describe('fleet server hosts handler', () => { it('should return ok on post in serverless if host url is same as default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await postFleetServerHost( + const res = await postFleetServerHostWithErrorHandler( mockContext, { body: { id: 'host1', host_urls: ['http://elasticsearch:9200'] } } as any, mockResponse as any @@ -76,7 +81,7 @@ describe('fleet server hosts handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await postFleetServerHost( + const res = await postFleetServerHostWithErrorHandler( mockContext, { body: { id: 'host1', host_urls: ['http://localhost:8080'] } } as any, mockResponse as any @@ -88,7 +93,7 @@ describe('fleet server hosts handler', () => { it('should return error on put in serverless if host url is different from default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await putFleetServerHostHandler( + const res = await putFleetServerHostHandlerWithErrorHandler( mockContext, { body: { host_urls: ['http://localhost:8080'] }, params: { outputId: 'host1' } } as any, mockResponse as any @@ -105,7 +110,7 @@ describe('fleet server hosts handler', () => { it('should return ok on put in serverless if host url is same as default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await putFleetServerHostHandler( + const res = await putFleetServerHostHandlerWithErrorHandler( mockContext, { body: { host_urls: ['http://elasticsearch:9200'] }, params: { outputId: 'host1' } } as any, mockResponse as any @@ -117,7 +122,7 @@ describe('fleet server hosts handler', () => { it('should return ok on put in serverless if host urls are not passed', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await putFleetServerHostHandler( + const res = await putFleetServerHostHandlerWithErrorHandler( mockContext, { body: { name: ['Renamed'] }, params: { outputId: 'host1' } } as any, mockResponse as any @@ -131,7 +136,7 @@ describe('fleet server hosts handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await putFleetServerHostHandler( + const res = await putFleetServerHostHandlerWithErrorHandler( mockContext, { body: { host_urls: ['http://localhost:8080'] }, params: { outputId: 'host1' } } as any, mockResponse as any diff --git a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts index 8ad69d585ffc7..52d370e3062b6 100644 --- a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts +++ b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts @@ -12,7 +12,7 @@ import { isEqual } from 'lodash'; import { SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID } from '../../constants'; -import { defaultFleetErrorHandler, FleetServerHostUnauthorizedError } from '../../errors'; +import { FleetServerHostUnauthorizedError } from '../../errors'; import { agentPolicyService, appContextService } from '../../services'; import { @@ -58,28 +58,24 @@ export const postFleetServerHost: RequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - // In serverless, allow create fleet server host if host url is same as default. - await checkFleetServerHostsWriteAPIsAllowed(soClient, request.body.host_urls); - - const { id, ...data } = request.body; - const FleetServerHost = await createFleetServerHost( - soClient, - { ...data, is_preconfigured: false }, - { id } - ); - if (FleetServerHost.is_default) { - await agentPolicyService.bumpAllAgentPolicies(esClient); - } - - const body = { - item: FleetServerHost, - }; + // In serverless, allow create fleet server host if host url is same as default. + await checkFleetServerHostsWriteAPIsAllowed(soClient, request.body.host_urls); - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + const { id, ...data } = request.body; + const FleetServerHost = await createFleetServerHost( + soClient, + { ...data, is_preconfigured: false }, + { id } + ); + if (FleetServerHost.is_default) { + await agentPolicyService.bumpAllAgentPolicies(esClient); } + + const body = { + item: FleetServerHost, + }; + + return response.ok({ body }); }; export const getFleetServerHostHandler: RequestHandler< @@ -100,7 +96,7 @@ export const getFleetServerHostHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -125,7 +121,7 @@ export const deleteFleetServerHostHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -163,23 +159,19 @@ export const putFleetServerHostHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; export const getAllFleetServerHostsHandler: RequestHandler = async (context, request, response) => { const soClient = (await context.core).savedObjects.client; - try { - const res = await listFleetServerHosts(soClient); - const body = { - items: res.items, - page: res.page, - perPage: res.perPage, - total: res.total, - }; - - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const res = await listFleetServerHosts(soClient); + const body = { + items: res.items, + page: res.page, + perPage: res.perPage, + total: res.total, + }; + + return response.ok({ body }); }; diff --git a/x-pack/plugins/fleet/server/routes/health_check/handler.ts b/x-pack/plugins/fleet/server/routes/health_check/handler.ts index 16114217bee2b..276edd9c6bb32 100644 --- a/x-pack/plugins/fleet/server/routes/health_check/handler.ts +++ b/x-pack/plugins/fleet/server/routes/health_check/handler.ts @@ -12,8 +12,6 @@ import { getFleetServerHost } from '../../services/fleet_server_host'; import type { FleetRequestHandler, PostHealthCheckRequestSchema } from '../../types'; -import { defaultFleetErrorHandler } from '../../errors'; - export const postHealthCheckHandler: FleetRequestHandler< undefined, undefined, @@ -72,6 +70,6 @@ export const postHealthCheckHandler: FleetRequestHandler< body: { status: `OFFLINE`, host_id: request.body.id }, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; diff --git a/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.test.ts b/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.test.ts index d9987f325338b..11b47a626f8d2 100644 --- a/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.test.ts @@ -12,9 +12,12 @@ import type { KibanaRequest } from '@kbn/core/server'; import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; import { appContextService } from '../../services'; import type { FleetRequestHandlerContext } from '../../types'; +import { withDefaultErrorHandler } from '../../services/security/fleet_router'; import { rotateKeyPairHandler } from './handlers'; +const rotateKeyPairHandlerWithErrorHandler = withDefaultErrorHandler(rotateKeyPairHandler); + describe('FleetMessageSigningServiceHandler', () => { let context: AwaitedProperties>; let response: ReturnType; @@ -51,7 +54,7 @@ describe('FleetMessageSigningServiceHandler', () => { messageSigningService: undefined, }); - await rotateKeyPairHandler( + await rotateKeyPairHandlerWithErrorHandler( coreMock.createCustomRequestHandlerContext(context), request, response @@ -65,7 +68,7 @@ describe('FleetMessageSigningServiceHandler', () => { }); it('POST /message_signing_service/rotate_key_pair?acknowledge=true succeeds with `acknowledge=true`', async () => { - await rotateKeyPairHandler( + await rotateKeyPairHandlerWithErrorHandler( coreMock.createCustomRequestHandlerContext(context), request, response @@ -89,7 +92,7 @@ describe('FleetMessageSigningServiceHandler', () => { Error(error) ); - await rotateKeyPairHandler( + await rotateKeyPairHandlerWithErrorHandler( coreMock.createCustomRequestHandlerContext(context), request, response diff --git a/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.ts b/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.ts index 7e2acdb5171a8..8bc21e18fbd78 100644 --- a/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/message_signing_service/handlers.ts @@ -9,7 +9,6 @@ import type { TypeOf } from '@kbn/config-schema'; import type { FleetRequestHandler } from '../../types'; -import { defaultFleetErrorHandler } from '../../errors'; import { appContextService } from '../../services'; import type { RotateKeyPairSchema } from '../../types/rest_spec/message_signing_service'; @@ -41,6 +40,6 @@ export const rotateKeyPairHandler: FleetRequestHandler< }); } catch (error) { logger.error(error); - return defaultFleetErrorHandler({ error: new Error('Failed to rotate key pair!'), response }); + throw new Error('Failed to rotate key pair!'); } }; diff --git a/x-pack/plugins/fleet/server/routes/output/handler.test.ts b/x-pack/plugins/fleet/server/routes/output/handler.test.ts index 26dd6099e474f..665cb9059654e 100644 --- a/x-pack/plugins/fleet/server/routes/output/handler.test.ts +++ b/x-pack/plugins/fleet/server/routes/output/handler.test.ts @@ -7,9 +7,13 @@ import { SERVERLESS_DEFAULT_OUTPUT_ID } from '../../constants'; import { agentPolicyService, appContextService, outputService } from '../../services'; +import { withDefaultErrorHandler } from '../../services/security/fleet_router'; import { postOutputHandler, putOutputHandler } from './handler'; +const putOutputHandlerWithErrorHandler = withDefaultErrorHandler(putOutputHandler); +const postOutputHandlerWithErrorHandler = withDefaultErrorHandler(postOutputHandler); + describe('output handler', () => { const mockContext = { core: Promise.resolve({ @@ -41,7 +45,7 @@ describe('output handler', () => { it('should return error on post output using remote_elasticsearch in serverless', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { id: 'output1', type: 'remote_elasticsearch' } } as any, mockResponse as any @@ -58,7 +62,7 @@ describe('output handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { type: 'remote_elasticsearch' } } as any, mockResponse as any @@ -70,7 +74,7 @@ describe('output handler', () => { it('should return error on put output using remote_elasticsearch in serverless', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await putOutputHandler( + const res = await putOutputHandlerWithErrorHandler( mockContext, { body: { type: 'remote_elasticsearch' }, params: { outputId: 'output1' } } as any, mockResponse as any @@ -87,7 +91,7 @@ describe('output handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await putOutputHandler( + const res = await putOutputHandlerWithErrorHandler( mockContext, { body: { type: 'remote_elasticsearch' }, params: { outputId: 'output1' } } as any, mockResponse as any @@ -99,7 +103,7 @@ describe('output handler', () => { it('should return error on post elasticsearch output in serverless if host url is different from default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { id: 'output1', type: 'elasticsearch', hosts: ['http://localhost:8080'] } } as any, mockResponse as any @@ -117,7 +121,7 @@ describe('output handler', () => { it('should return ok on post elasticsearch output in serverless if host url is same as default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { id: 'output1', type: 'elasticsearch', hosts: ['http://elasticsearch:9200'] }, @@ -133,7 +137,7 @@ describe('output handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { id: 'output1', type: 'elasticsearch', hosts: ['http://localhost:8080'] } } as any, mockResponse as any @@ -153,7 +157,7 @@ describe('output handler', () => { } }); - const res = await putOutputHandler( + const res = await putOutputHandlerWithErrorHandler( mockContext, { body: { hosts: ['http://localhost:8080'] }, @@ -174,7 +178,7 @@ describe('output handler', () => { it('should return ok on put elasticsearch output in serverless if host url is same as default', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await putOutputHandler( + const res = await putOutputHandlerWithErrorHandler( mockContext, { body: { hosts: ['http://elasticsearch:9200'] }, @@ -189,7 +193,7 @@ describe('output handler', () => { it('should return ok on put elasticsearch output in serverless if host url is not passed', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - const res = await putOutputHandler( + const res = await putOutputHandlerWithErrorHandler( mockContext, { body: { name: 'Renamed output' }, @@ -206,7 +210,7 @@ describe('output handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await putOutputHandler( + const res = await putOutputHandlerWithErrorHandler( mockContext, { body: { hosts: ['http://localhost:8080'] }, @@ -223,7 +227,7 @@ describe('output handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { @@ -246,7 +250,7 @@ describe('output handler', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - const res = await postOutputHandler( + const res = await postOutputHandlerWithErrorHandler( mockContext, { body: { type: 'remote_elasticsearch', secrets: { service_token: 'token2' } } } as any, mockResponse as any diff --git a/x-pack/plugins/fleet/server/routes/output/handler.ts b/x-pack/plugins/fleet/server/routes/output/handler.ts index b35158637d151..0b33dd15e73fe 100644 --- a/x-pack/plugins/fleet/server/routes/output/handler.ts +++ b/x-pack/plugins/fleet/server/routes/output/handler.ts @@ -29,7 +29,7 @@ import type { PostLogstashApiKeyResponse, } from '../../../common/types'; import { outputService } from '../../services/output'; -import { defaultFleetErrorHandler, FleetUnauthorizedError } from '../../errors'; +import { FleetUnauthorizedError } from '../../errors'; import { agentPolicyService, appContextService } from '../../services'; import { generateLogstashApiKey, canCreateLogstashApiKey } from '../../services/api_keys'; @@ -55,20 +55,16 @@ function ensureNoDuplicateSecrets(output: Partial) { export const getOutputsHandler: RequestHandler = async (context, request, response) => { const soClient = (await context.core).savedObjects.client; - try { - const outputs = await outputService.list(soClient); + const outputs = await outputService.list(soClient); - const body: GetOutputsResponse = { - items: outputs.items, - page: outputs.page, - perPage: outputs.perPage, - total: outputs.total, - }; + const body: GetOutputsResponse = { + items: outputs.items, + page: outputs.page, + perPage: outputs.perPage, + total: outputs.total, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getOneOuputHandler: RequestHandler< @@ -90,7 +86,7 @@ export const getOneOuputHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -126,7 +122,7 @@ export const putOutputHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -138,23 +134,19 @@ export const postOutputHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - const { id, ...newOutput } = request.body; - await validateOutputServerless(newOutput, soClient); - ensureNoDuplicateSecrets(newOutput); - const output = await outputService.create(soClient, esClient, newOutput, { id }); - if (output.is_default || output.is_default_monitoring) { - await agentPolicyService.bumpAllAgentPolicies(esClient); - } + const { id, ...newOutput } = request.body; + await validateOutputServerless(newOutput, soClient); + ensureNoDuplicateSecrets(newOutput); + const output = await outputService.create(soClient, esClient, newOutput, { id }); + if (output.is_default || output.is_default_monitoring) { + await agentPolicyService.bumpAllAgentPolicies(esClient); + } - const body: GetOneOutputResponse = { - item: output, - }; + const body: GetOneOutputResponse = { + item: output, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; async function validateOutputServerless( @@ -206,42 +198,31 @@ export const deleteOutputHandler: RequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; export const postLogstashApiKeyHandler: RequestHandler = async (context, request, response) => { const esClient = (await context.core).elasticsearch.client.asCurrentUser; - try { - const hasCreatePrivileges = await canCreateLogstashApiKey(esClient); - if (!hasCreatePrivileges) { - throw new FleetUnauthorizedError('Missing permissions to create logstash API key'); - } + const hasCreatePrivileges = await canCreateLogstashApiKey(esClient); + if (!hasCreatePrivileges) { + throw new FleetUnauthorizedError('Missing permissions to create logstash API key'); + } - const apiKey = await generateLogstashApiKey(esClient); + const apiKey = await generateLogstashApiKey(esClient); - const body: PostLogstashApiKeyResponse = { - // Logstash expect the key to be formatted like this id:key - api_key: `${apiKey.id}:${apiKey.api_key}`, - }; + const body: PostLogstashApiKeyResponse = { + // Logstash expect the key to be formatted like this id:key + api_key: `${apiKey.id}:${apiKey.api_key}`, + }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ body }); }; export const getLatestOutputHealth: RequestHandler< TypeOf > = async (context, request, response) => { const esClient = (await context.core).elasticsearch.client.asInternalUser; - try { - const outputHealth = await outputService.getLatestOutputHealth( - esClient, - request.params.outputId - ); - return response.ok({ body: outputHealth }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const outputHealth = await outputService.getLatestOutputHealth(esClient, request.params.outputId); + return response.ok({ body: outputHealth }); }; diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index 05876c2bbdf26..5c19ee75ddade 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -407,13 +407,10 @@ describe('When calling package policy', () => { .spyOn(appContextService, 'getExperimentalFeatures') .mockReturnValue({ enableReusableIntegrationPolicies: true } as any); const request = getUpdateKibanaRequest({ policy_ids: ['1', '2'] } as any); - await routeHandler(context, request, response); - expect(response.customError).toHaveBeenCalledWith({ - statusCode: 400, - body: { - message: 'Cannot change agent policies of an agentless integration', - }, - }); + + await expect(() => routeHandler(context, request, response)).rejects.toThrow( + /Cannot change agent policies of an agentless integration/ + ); }); it('should rename the agentless agent policy to sync with the package policy name if agentless is enabled', async () => { diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index 3bc2f79d6afd7..6fe1e897a9772 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -43,11 +43,7 @@ import type { UpgradePackagePolicyResponse, } from '../../../common/types'; import { installationStatuses, inputsFormat } from '../../../common/constants'; -import { - defaultFleetErrorHandler, - PackagePolicyNotFoundError, - PackagePolicyRequestError, -} from '../../errors'; +import { PackagePolicyNotFoundError, PackagePolicyRequestError } from '../../errors'; import { getInstallation, getInstallations, @@ -80,33 +76,26 @@ export const getPackagePoliciesHandler: FleetRequestHandler< const soClient = fleetContext.internalSoClient; const limitedToPackages = fleetContext.limitedToPackages; - try { - const { items, total, page, perPage } = await packagePolicyService.list( - soClient, - request.query - ); - - checkAllowedPackages(items, limitedToPackages, 'package.name'); + const { items, total, page, perPage } = await packagePolicyService.list(soClient, request.query); - if (request.query.withAgentCount) { - await populatePackagePolicyAssignedAgentsCount(esClient, items); - } + checkAllowedPackages(items, limitedToPackages, 'package.name'); - // agnostic to package-level RBAC - return response.ok({ - body: { - items: - request.query.format === inputsFormat.Simplified - ? items.map((item) => packagePolicyToSimplifiedPackagePolicy(item)) - : items, - total, - page, - perPage, - }, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + if (request.query.withAgentCount) { + await populatePackagePolicyAssignedAgentsCount(esClient, items); } + + // agnostic to package-level RBAC + return response.ok({ + body: { + items: + request.query.format === inputsFormat.Simplified + ? items.map((item) => packagePolicyToSimplifiedPackagePolicy(item)) + : items, + total, + page, + perPage, + }, + }); }; export const bulkGetPackagePoliciesHandler: FleetRequestHandler< @@ -142,7 +131,7 @@ export const bulkGetPackagePoliciesHandler: FleetRequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -177,9 +166,8 @@ export const getOnePackagePolicyHandler: FleetRequestHandler< } catch (error) { if (SavedObjectsErrorHelpers.isNotFoundError(error)) { return notFoundResponse(); - } else { - return defaultFleetErrorHandler({ error, response }); } + throw error; } }; @@ -189,42 +177,39 @@ export const getOrphanedPackagePolicies: RequestHandler = response ) => { const soClient = (await context.core).savedObjects.client; - try { - const installedPackages = await getInstallations(soClient, { - perPage: SO_SEARCH_LIMIT, - filter: ` + + const installedPackages = await getInstallations(soClient, { + perPage: SO_SEARCH_LIMIT, + filter: ` ${PACKAGES_SAVED_OBJECT_TYPE}.attributes.install_status:${installationStatuses.Installed} `, + }); + const orphanedPackagePolicies: PackagePolicy[] = []; + const packagePolicies = await packagePolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + }); + const packagePoliciesByPackage = groupBy(packagePolicies.items, 'package.name'); + const agentPolicies = await agentPolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + }); + const agentPoliciesById = keyBy(agentPolicies.items, 'id'); + const usedPackages = installedPackages.saved_objects.filter( + ({ attributes: { name } }) => !!packagePoliciesByPackage[name] + ); + usedPackages.forEach(({ attributes: { name } }) => { + packagePoliciesByPackage[name].forEach((packagePolicy) => { + if (packagePolicy.policy_ids.every((policyId) => !agentPoliciesById[policyId])) { + orphanedPackagePolicies.push(packagePolicy); + } }); - const orphanedPackagePolicies: PackagePolicy[] = []; - const packagePolicies = await packagePolicyService.list(soClient, { - perPage: SO_SEARCH_LIMIT, - }); - const packagePoliciesByPackage = groupBy(packagePolicies.items, 'package.name'); - const agentPolicies = await agentPolicyService.list(soClient, { - perPage: SO_SEARCH_LIMIT, - }); - const agentPoliciesById = keyBy(agentPolicies.items, 'id'); - const usedPackages = installedPackages.saved_objects.filter( - ({ attributes: { name } }) => !!packagePoliciesByPackage[name] - ); - usedPackages.forEach(({ attributes: { name } }) => { - packagePoliciesByPackage[name].forEach((packagePolicy) => { - if (packagePolicy.policy_ids.every((policyId) => !agentPoliciesById[policyId])) { - orphanedPackagePolicies.push(packagePolicy); - } - }); - }); - - return response.ok({ - body: { - items: orphanedPackagePolicies, - total: orphanedPackagePolicies.length, - }, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + }); + + return response.ok({ + body: { + items: orphanedPackagePolicies, + total: orphanedPackagePolicies.length, + }, + }); }; export const createPackagePolicyHandler: FleetRequestHandler< @@ -323,7 +308,7 @@ export const createPackagePolicyHandler: FleetRequestHandler< body: { message: error.message }, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -441,7 +426,7 @@ export const updatePackagePolicyHandler: FleetRequestHandler< body: { message: error.message }, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -455,22 +440,18 @@ export const deletePackagePolicyHandler: RequestHandler< const esClient = coreContext.elasticsearch.client.asInternalUser; const user = appContextService.getSecurityCore().authc.getCurrentUser(request) || undefined; - try { - const body: PostDeletePackagePoliciesResponse = await packagePolicyService.delete( - soClient, - esClient, - request.body.packagePolicyIds, - { user, force: request.body.force, skipUnassignFromAgentPolicies: request.body.force }, - context, - request - ); - - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const body: PostDeletePackagePoliciesResponse = await packagePolicyService.delete( + soClient, + esClient, + request.body.packagePolicyIds, + { user, force: request.body.force, skipUnassignFromAgentPolicies: request.body.force }, + context, + request + ); + + return response.ok({ + body, + }); }; export const deleteOnePackagePolicyHandler: RequestHandler< @@ -483,33 +464,29 @@ export const deleteOnePackagePolicyHandler: RequestHandler< const esClient = coreContext.elasticsearch.client.asInternalUser; const user = appContextService.getSecurityCore().authc.getCurrentUser(request) || undefined; - try { - const res = await packagePolicyService.delete( - soClient, - esClient, - [request.params.packagePolicyId], - { user, force: request.query.force, skipUnassignFromAgentPolicies: request.query.force }, - context, - request - ); - - if ( - res[0] && - res[0].success === false && - res[0].statusCode !== 404 // ignore 404 to allow that call to be idempotent - ) { - return response.customError({ - statusCode: res[0].statusCode ?? 500, - body: res[0].body, - }); - } - - return response.ok({ - body: { id: request.params.packagePolicyId }, + const res = await packagePolicyService.delete( + soClient, + esClient, + [request.params.packagePolicyId], + { user, force: request.query.force, skipUnassignFromAgentPolicies: request.query.force }, + context, + request + ); + + if ( + res[0] && + res[0].success === false && + res[0].statusCode !== 404 // ignore 404 to allow that call to be idempotent + ) { + return response.customError({ + statusCode: res[0].statusCode ?? 500, + body: res[0].body, }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); } + + return response.ok({ + body: { id: request.params.packagePolicyId }, + }); }; export const upgradePackagePolicyHandler: RequestHandler< @@ -521,28 +498,24 @@ export const upgradePackagePolicyHandler: RequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; const user = appContextService.getSecurityCore().authc.getCurrentUser(request) || undefined; - try { - const body: UpgradePackagePolicyResponse = await packagePolicyService.upgrade( - soClient, - esClient, - request.body.packagePolicyIds, - { user } - ); - - const firstFatalError = body.find((item) => item.statusCode && item.statusCode !== 200); - - if (firstFatalError) { - return response.customError({ - statusCode: firstFatalError.statusCode!, - body: { message: firstFatalError.body!.message }, - }); - } - return response.ok({ - body, + const body: UpgradePackagePolicyResponse = await packagePolicyService.upgrade( + soClient, + esClient, + request.body.packagePolicyIds, + { user } + ); + + const firstFatalError = body.find((item) => item.statusCode && item.statusCode !== 200); + + if (firstFatalError) { + return response.customError({ + statusCode: firstFatalError.statusCode!, + body: { message: firstFatalError.body!.message }, }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); } + return response.ok({ + body, + }); }; export const dryRunUpgradePackagePolicyHandler: RequestHandler< @@ -551,28 +524,25 @@ export const dryRunUpgradePackagePolicyHandler: RequestHandler< TypeOf > = async (context, request, response) => { const soClient = (await context.core).savedObjects.client; - try { - const body: UpgradePackagePolicyDryRunResponse = []; - const { packagePolicyIds } = request.body; - for (const id of packagePolicyIds) { - const result = await packagePolicyService.getUpgradeDryRunDiff(soClient, id); - body.push(result); - } + const body: UpgradePackagePolicyDryRunResponse = []; + const { packagePolicyIds } = request.body; - const firstFatalError = body.find((item) => item.statusCode && item.statusCode !== 200); + for (const id of packagePolicyIds) { + const result = await packagePolicyService.getUpgradeDryRunDiff(soClient, id); + body.push(result); + } - if (firstFatalError) { - return response.customError({ - statusCode: firstFatalError.statusCode!, - body: { message: firstFatalError.body!.message }, - }); - } + const firstFatalError = body.find((item) => item.statusCode && item.statusCode !== 200); - return response.ok({ - body, + if (firstFatalError) { + return response.customError({ + statusCode: firstFatalError.statusCode!, + body: { message: firstFatalError.body!.message }, }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); } + + return response.ok({ + body, + }); }; diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts index 7b2956b7ec460..ad4f3ddf17721 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts @@ -9,7 +9,6 @@ import type { TypeOf } from '@kbn/config-schema'; import type { FleetRequestHandler } from '../../types'; import type { PostResetOnePreconfiguredAgentPoliciesSchema } from '../../types'; -import { defaultFleetErrorHandler } from '../../errors'; import { resetPreconfiguredAgentPolicies } from '../../services/preconfiguration/reset_agent_policies'; export const resetOnePreconfigurationHandler: FleetRequestHandler< @@ -21,12 +20,8 @@ export const resetOnePreconfigurationHandler: FleetRequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - await resetPreconfiguredAgentPolicies(soClient, esClient, request.params.agentPolicyId); - return response.ok({}); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + await resetPreconfiguredAgentPolicies(soClient, esClient, request.params.agentPolicyId); + return response.ok({}); }; export const resetPreconfigurationHandler: FleetRequestHandler< @@ -38,10 +33,6 @@ export const resetPreconfigurationHandler: FleetRequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - try { - await resetPreconfiguredAgentPolicies(soClient, esClient); - return response.ok({}); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + await resetPreconfiguredAgentPolicies(soClient, esClient); + return response.ok({}); }; diff --git a/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts b/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts index 69bf95207f82c..6836faa2060aa 100644 --- a/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts +++ b/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts @@ -17,7 +17,6 @@ import type { EnrollmentSettingsFleetServerPolicy, } from '../../../common/types'; import type { FleetRequestHandler, GetEnrollmentSettingsRequestSchema } from '../../types'; -import { defaultFleetErrorHandler } from '../../errors'; import { agentPolicyService, appContextService, downloadSourceService } from '../../services'; import { getFleetServerHostsForAgentPolicy } from '../../services/fleet_server_host'; import { getFleetProxy } from '../../services/fleet_proxies'; @@ -38,99 +37,95 @@ export const getEnrollmentSettingsHandler: FleetRequestHandler< const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; const soClient = coreContext.savedObjects.client; + // Get all possible fleet server or scoped normal agent policies + const { fleetServerPolicies, scopedAgentPolicy: scopedAgentPolicyResponse } = + await getFleetServerOrAgentPolicies(soClient, agentPolicyId); + const scopedAgentPolicy = scopedAgentPolicyResponse || { + id: undefined, + name: undefined, + fleet_server_host_id: undefined, + download_source_id: undefined, + data_output_id: undefined, + }; + // Check if there is any active fleet server enrolled into the fleet server policies policies + if (fleetServerPolicies) { + settingsResponse.fleet_server.policies = fleetServerPolicies; + settingsResponse.fleet_server.has_active = await hasFleetServersForPolicies( + esClient, + appContextService.getInternalUserSOClientWithoutSpaceExtension(), + fleetServerPolicies, + true + ); + } + + // Get download source + // ignore errors if the download source is not found try { - // Get all possible fleet server or scoped normal agent policies - const { fleetServerPolicies, scopedAgentPolicy: scopedAgentPolicyResponse } = - await getFleetServerOrAgentPolicies(soClient, agentPolicyId); - const scopedAgentPolicy = scopedAgentPolicyResponse || { - id: undefined, - name: undefined, - fleet_server_host_id: undefined, - download_source_id: undefined, - data_output_id: undefined, - }; - // Check if there is any active fleet server enrolled into the fleet server policies policies - if (fleetServerPolicies) { - settingsResponse.fleet_server.policies = fleetServerPolicies; - settingsResponse.fleet_server.has_active = await hasFleetServersForPolicies( - esClient, - appContextService.getInternalUserSOClientWithoutSpaceExtension(), - fleetServerPolicies, - true - ); - } + settingsResponse.download_source = await getDownloadSource( + soClient, + scopedAgentPolicy.download_source_id ?? undefined + ); + } catch (e) { + settingsResponse.download_source = undefined; + } - // Get download source - // ignore errors if the download source is not found - try { - settingsResponse.download_source = await getDownloadSource( + // Get download source proxy + // ignore errors if the download source proxy is not found + try { + if (settingsResponse.download_source?.proxy_id) { + settingsResponse.download_source_proxy = await getFleetProxy( soClient, - scopedAgentPolicy.download_source_id ?? undefined + settingsResponse.download_source.proxy_id ); - } catch (e) { - settingsResponse.download_source = undefined; } + } catch (e) { + settingsResponse.download_source_proxy = undefined; + } - // Get download source proxy - // ignore errors if the download source proxy is not found - try { - if (settingsResponse.download_source?.proxy_id) { - settingsResponse.download_source_proxy = await getFleetProxy( - soClient, - settingsResponse.download_source.proxy_id - ); - } - } catch (e) { - settingsResponse.download_source_proxy = undefined; - } + // Get associated fleet server host, or default one if it doesn't exist + // `getFleetServerHostsForAgentPolicy` errors if there is no default, so catch it + try { + settingsResponse.fleet_server.host = await getFleetServerHostsForAgentPolicy( + soClient, + scopedAgentPolicy + ); + } catch (e) { + settingsResponse.fleet_server.host = undefined; + } - // Get associated fleet server host, or default one if it doesn't exist - // `getFleetServerHostsForAgentPolicy` errors if there is no default, so catch it - try { - settingsResponse.fleet_server.host = await getFleetServerHostsForAgentPolicy( + // If a fleet server host was found, get associated fleet server host proxy if any + // ignore errors if the proxy is not found + try { + if (settingsResponse.fleet_server.host?.proxy_id) { + settingsResponse.fleet_server.host_proxy = await getFleetProxy( soClient, - scopedAgentPolicy + settingsResponse.fleet_server.host.proxy_id ); - } catch (e) { - settingsResponse.fleet_server.host = undefined; - } - - // If a fleet server host was found, get associated fleet server host proxy if any - // ignore errors if the proxy is not found - try { - if (settingsResponse.fleet_server.host?.proxy_id) { - settingsResponse.fleet_server.host_proxy = await getFleetProxy( - soClient, - settingsResponse.fleet_server.host.proxy_id - ); - } - } catch (e) { - settingsResponse.fleet_server.host_proxy = undefined; } + } catch (e) { + settingsResponse.fleet_server.host_proxy = undefined; + } - // Get associated output and proxy (if any) to use for Fleet Server enrollment - try { - if (settingsResponse.fleet_server.policies.length > 0) { - const dataOutput = await getDataOutputForAgentPolicy(soClient, scopedAgentPolicy); - if (dataOutput.type === 'elasticsearch' && dataOutput.hosts?.[0]) { - settingsResponse.fleet_server.es_output = dataOutput; - if (dataOutput.proxy_id) { - settingsResponse.fleet_server.es_output_proxy = await getFleetProxy( - soClient, - dataOutput.proxy_id - ); - } + // Get associated output and proxy (if any) to use for Fleet Server enrollment + try { + if (settingsResponse.fleet_server.policies.length > 0) { + const dataOutput = await getDataOutputForAgentPolicy(soClient, scopedAgentPolicy); + if (dataOutput.type === 'elasticsearch' && dataOutput.hosts?.[0]) { + settingsResponse.fleet_server.es_output = dataOutput; + if (dataOutput.proxy_id) { + settingsResponse.fleet_server.es_output_proxy = await getFleetProxy( + soClient, + dataOutput.proxy_id + ); } } - } catch (e) { - settingsResponse.fleet_server.es_output = undefined; - settingsResponse.fleet_server.es_output_proxy = undefined; } - - return response.ok({ body: settingsResponse }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + } catch (e) { + settingsResponse.fleet_server.es_output = undefined; + settingsResponse.fleet_server.es_output_proxy = undefined; } + + return response.ok({ body: settingsResponse }); }; export const getFleetServerOrAgentPolicies = async ( diff --git a/x-pack/plugins/fleet/server/routes/settings/settings_handler.ts b/x-pack/plugins/fleet/server/routes/settings/settings_handler.ts index 4123c2ea37e68..96bc4e0cd789e 100644 --- a/x-pack/plugins/fleet/server/routes/settings/settings_handler.ts +++ b/x-pack/plugins/fleet/server/routes/settings/settings_handler.ts @@ -12,21 +12,16 @@ import type { PutSettingsRequestSchema, PutSpaceSettingsRequestSchema, } from '../../types'; -import { defaultFleetErrorHandler } from '../../errors'; import { settingsService, agentPolicyService, appContextService } from '../../services'; import { getSpaceSettings, saveSpaceSettings } from '../../services/spaces/space_settings'; export const getSpaceSettingsHandler: FleetRequestHandler = async (context, request, response) => { - try { - const soClient = (await context.fleet).internalSoClient; - const settings = await getSpaceSettings(soClient.getCurrentNamespace()); - const body = { - item: settings, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const soClient = (await context.fleet).internalSoClient; + const settings = await getSpaceSettings(soClient.getCurrentNamespace()); + const body = { + item: settings, + }; + return response.ok({ body }); }; export const putSpaceSettingsHandler: FleetRequestHandler< @@ -34,22 +29,18 @@ export const putSpaceSettingsHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - try { - const soClient = (await context.fleet).internalSoClient; - await saveSpaceSettings({ - settings: { - allowed_namespace_prefixes: request.body.allowed_namespace_prefixes, - }, - spaceId: soClient.getCurrentNamespace(), - }); - const settings = await getSpaceSettings(soClient.getCurrentNamespace()); - const body = { - item: settings, - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const soClient = (await context.fleet).internalSoClient; + await saveSpaceSettings({ + settings: { + allowed_namespace_prefixes: request.body.allowed_namespace_prefixes, + }, + spaceId: soClient.getCurrentNamespace(), + }); + const settings = await getSpaceSettings(soClient.getCurrentNamespace()); + const body = { + item: settings, + }; + return response.ok({ body }); }; export const getSettingsHandler: FleetRequestHandler = async (context, request, response) => { @@ -68,7 +59,7 @@ export const getSettingsHandler: FleetRequestHandler = async (context, request, }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; @@ -95,6 +86,6 @@ export const putSettingsHandler: FleetRequestHandler< }); } - return defaultFleetErrorHandler({ error, response }); + throw error; } }; diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts index 79544e7a4e932..9d524969d8ed3 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -23,10 +23,14 @@ import { setupFleet } from '../../services/setup'; import type { FleetRequestHandlerContext } from '../../types'; import { hasFleetServers } from '../../services/fleet_server'; import { createFleetAuthzMock } from '../../../common/mocks'; +import { withDefaultErrorHandler } from '../../services/security/fleet_router'; import { fleetSetupHandler, getFleetStatusHandler } from './handlers'; import { FleetSetupResponseSchema, GetAgentsSetupResponseSchema } from '.'; +const fleetSetupWithErrorHandler = withDefaultErrorHandler(fleetSetupHandler); +const getFleetStatusWithErrorHandler = withDefaultErrorHandler(getFleetStatusHandler); + jest.mock('../../services/setup', () => { return { ...jest.requireActual('../../services/setup'), @@ -87,7 +91,11 @@ describe('FleetSetupHandler', () => { nonFatalErrors: [], }) ); - await fleetSetupHandler(coreMock.createCustomRequestHandlerContext(context), request, response); + await fleetSetupWithErrorHandler( + coreMock.createCustomRequestHandlerContext(context), + request, + response + ); const expectedBody: PostFleetSetupResponse = { isInitialized: true, @@ -101,7 +109,11 @@ describe('FleetSetupHandler', () => { it('POST /setup fails w/500 on custom error', async () => { mockSetupFleet.mockImplementation(() => Promise.reject(new Error('SO method mocked to throw'))); - await fleetSetupHandler(coreMock.createCustomRequestHandlerContext(context), request, response); + await fleetSetupWithErrorHandler( + coreMock.createCustomRequestHandlerContext(context), + request, + response + ); expect(response.customError).toHaveBeenCalledTimes(1); expect(response.customError).toHaveBeenCalledWith({ @@ -117,7 +129,11 @@ describe('FleetSetupHandler', () => { Promise.reject(new RegistryError('Registry method mocked to throw')) ); - await fleetSetupHandler(coreMock.createCustomRequestHandlerContext(context), request, response); + await fleetSetupWithErrorHandler( + coreMock.createCustomRequestHandlerContext(context), + request, + response + ); expect(response.customError).toHaveBeenCalledTimes(1); expect(response.customError).toHaveBeenCalledWith({ statusCode: 502, @@ -175,7 +191,7 @@ describe('FleetStatusHandler', () => { .mocked(appContextService.getSecurity().authc.apiKeys.areAPIKeysEnabled) .mockResolvedValue(true); jest.mocked(hasFleetServers).mockResolvedValue(true); - await getFleetStatusHandler( + await getFleetStatusWithErrorHandler( coreMock.createCustomRequestHandlerContext(context), request, response @@ -197,7 +213,7 @@ describe('FleetStatusHandler', () => { .mocked(appContextService.getSecurity().authc.apiKeys.areAPIKeysEnabled) .mockResolvedValue(false); jest.mocked(hasFleetServers).mockResolvedValue(false); - await getFleetStatusHandler( + await getFleetStatusWithErrorHandler( coreMock.createCustomRequestHandlerContext(context), request, response @@ -228,7 +244,7 @@ describe('FleetStatusHandler', () => { jest .mocked(appContextService.getSecurity().authc.apiKeys.areAPIKeysEnabled) .mockResolvedValue(true); - await getFleetStatusHandler( + await getFleetStatusWithErrorHandler( coreMock.createCustomRequestHandlerContext(context), request, response diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index 05ee55320d445..afe53ba0ed58c 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -9,7 +9,6 @@ import { appContextService } from '../../services'; import type { GetFleetStatusResponse, PostFleetSetupResponse } from '../../../common/types'; import { formatNonFatalErrors, setupFleet } from '../../services/setup'; import { hasFleetServers } from '../../services/fleet_server'; -import { defaultFleetErrorHandler } from '../../errors'; import type { FleetRequestHandler } from '../../types'; import { getGpgKeyIdOrUndefined } from '../../services/epm/packages/package_verification'; import { isSecretStorageEnabled } from '../../services/secrets'; @@ -21,69 +20,59 @@ export const getFleetStatusHandler: FleetRequestHandler = async (context, reques const esClient = coreContext.elasticsearch.client.asInternalUser; const soClient = appContextService.getInternalUserSOClientWithoutSpaceExtension(); - try { - const isApiKeysEnabled = await appContextService - .getSecurity() - .authc.apiKeys.areAPIKeysEnabled(); + const isApiKeysEnabled = await appContextService.getSecurity().authc.apiKeys.areAPIKeysEnabled(); - const [hasFleetServersRes, useSecretsStorage, isSpaceAwarenessEnabledRes] = await Promise.all([ - hasFleetServers(esClient, soClient), - isSecretStorageEnabled(esClient, soClient), - isSpaceAwarenessEnabled(), - ]); + const [hasFleetServersRes, useSecretsStorage, isSpaceAwarenessEnabledRes] = await Promise.all([ + hasFleetServers(esClient, soClient), + isSecretStorageEnabled(esClient, soClient), + isSpaceAwarenessEnabled(), + ]); - const isFleetServerMissing = !hasFleetServersRes; + const isFleetServerMissing = !hasFleetServersRes; - const isFleetServerStandalone = - appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; - const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; - const missingOptionalFeatures: GetFleetStatusResponse['missing_optional_features'] = []; + const isFleetServerStandalone = + appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; + const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; + const missingOptionalFeatures: GetFleetStatusResponse['missing_optional_features'] = []; - if (!isApiKeysEnabled) { - missingRequirements.push('api_keys'); - } - - if (!isFleetServerStandalone && isFleetServerMissing) { - missingRequirements.push('fleet_server'); - } + if (!isApiKeysEnabled) { + missingRequirements.push('api_keys'); + } - if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) { - missingOptionalFeatures.push('encrypted_saved_object_encryption_key_required'); - } + if (!isFleetServerStandalone && isFleetServerMissing) { + missingRequirements.push('fleet_server'); + } - const body: GetFleetStatusResponse = { - isReady: missingRequirements.length === 0, - missing_requirements: missingRequirements, - missing_optional_features: missingOptionalFeatures, - is_secrets_storage_enabled: useSecretsStorage, - is_space_awareness_enabled: isSpaceAwarenessEnabledRes, - }; + if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) { + missingOptionalFeatures.push('encrypted_saved_object_encryption_key_required'); + } - const packageVerificationKeyId = await getGpgKeyIdOrUndefined(); + const body: GetFleetStatusResponse = { + isReady: missingRequirements.length === 0, + missing_requirements: missingRequirements, + missing_optional_features: missingOptionalFeatures, + is_secrets_storage_enabled: useSecretsStorage, + is_space_awareness_enabled: isSpaceAwarenessEnabledRes, + }; - if (packageVerificationKeyId) { - body.package_verification_key_id = packageVerificationKeyId; - } + const packageVerificationKeyId = await getGpgKeyIdOrUndefined(); - return response.ok({ - body, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + if (packageVerificationKeyId) { + body.package_verification_key_id = packageVerificationKeyId; } + + return response.ok({ + body, + }); }; export const fleetSetupHandler: FleetRequestHandler = async (context, request, response) => { - try { - const soClient = (await context.fleet).internalSoClient; - const esClient = (await context.core).elasticsearch.client.asInternalUser; - const setupStatus = await setupFleet(soClient, esClient); - const body: PostFleetSetupResponse = { - ...setupStatus, - nonFatalErrors: formatNonFatalErrors(setupStatus.nonFatalErrors), - }; - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + const soClient = (await context.fleet).internalSoClient; + const esClient = (await context.core).elasticsearch.client.asInternalUser; + const setupStatus = await setupFleet(soClient, esClient); + const body: PostFleetSetupResponse = { + ...setupStatus, + nonFatalErrors: formatNonFatalErrors(setupStatus.nonFatalErrors), + }; + return response.ok({ body }); }; diff --git a/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts index 60e0c40c03f40..787cafcb9ac0d 100644 --- a/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts +++ b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts @@ -13,34 +13,30 @@ import { INDEX_PRIVILEGES, canCreateStandaloneAgentApiKey, } from '../../services/api_keys/create_standalone_agent_api_key'; -import { FleetUnauthorizedError, defaultFleetErrorHandler } from '../../errors'; +import { FleetUnauthorizedError } from '../../errors'; export const createStandaloneAgentApiKeyHandler: FleetRequestHandler< undefined, undefined, TypeOf > = async (context, request, response) => { - try { - const coreContext = await context.core; - const esClient = coreContext.elasticsearch.client.asCurrentUser; - const canCreate = await canCreateStandaloneAgentApiKey(esClient); + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const canCreate = await canCreateStandaloneAgentApiKey(esClient); - if (!canCreate) { - throw new FleetUnauthorizedError( - `Missing permissions to create standalone API key, You need ${INDEX_PRIVILEGES.privileges.join( - ', ' - )} for indices ${INDEX_PRIVILEGES.names.join(', ')}` - ); - } + if (!canCreate) { + throw new FleetUnauthorizedError( + `Missing permissions to create standalone API key, You need ${INDEX_PRIVILEGES.privileges.join( + ', ' + )} for indices ${INDEX_PRIVILEGES.names.join(', ')}` + ); + } - const key = await createStandaloneAgentApiKey(esClient, request.body.name); + const key = await createStandaloneAgentApiKey(esClient, request.body.name); - return response.ok({ - body: { - item: key, - }, - }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); - } + return response.ok({ + body: { + item: key, + }, + }); }; diff --git a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts index d7432fa7f2f51..b1736df2cc58f 100644 --- a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts @@ -10,7 +10,10 @@ import type { KibanaRequest, VersionedRouter } from '@kbn/core-http-server'; import { httpServerMock, coreMock, loggingSystemMock } from '@kbn/core/server/mocks'; import type { RequestHandler } from '@kbn/core/server'; -import { makeRouterWithFleetAuthz } from '../../services/security/fleet_router'; +import { + makeRouterWithFleetAuthz, + withDefaultErrorHandler, +} from '../../services/security/fleet_router'; import type { FleetAuthzRouter } from '../../services/security/types'; import type { @@ -41,6 +44,11 @@ import { registerRoutes } from '.'; import { getUninstallTokenHandler, getUninstallTokensMetadataHandler } from './handlers'; +const getUninstallTokenHandlerWithErrorHandler = withDefaultErrorHandler(getUninstallTokenHandler); +const getUninstallTokensMetadataHandlerWithErrorHandler = withDefaultErrorHandler( + getUninstallTokensMetadataHandler +); + jest.mock('../../services/agent_policy'); describe('uninstall token handlers', () => { @@ -113,7 +121,7 @@ describe('uninstall token handlers', () => { it('should return uninstall tokens for all policies', async () => { getTokenMetadataMock.mockResolvedValue(uninstallTokensResponseFixture); - await getUninstallTokensMetadataHandler(context, request, response); + await getUninstallTokensMetadataHandlerWithErrorHandler(context, request, response); expect(response.ok).toHaveBeenCalledWith({ body: uninstallTokensResponseFixture, @@ -127,7 +135,7 @@ describe('uninstall token handlers', () => { it('should return internal error when uninstallTokenService throws error', async () => { getTokenMetadataMock.mockRejectedValue(Error('something happened')); - await getUninstallTokensMetadataHandler(context, request, response); + await getUninstallTokensMetadataHandlerWithErrorHandler(context, request, response); expect(response.customError).toHaveBeenCalledWith({ statusCode: 500, @@ -164,7 +172,7 @@ describe('uninstall token handlers', () => { it('should return requested uninstall token', async () => { getTokenMock.mockResolvedValue(uninstallTokenFixture); - await getUninstallTokenHandler(context, request, response); + await getUninstallTokenHandlerWithErrorHandler(context, request, response); expect(getTokenMock).toHaveBeenCalledWith(uninstallTokenFixture.id); expect(response.ok).toHaveBeenCalledWith({ @@ -181,7 +189,7 @@ describe('uninstall token handlers', () => { it('should return internal error when uninstallTokenService throws error', async () => { getTokenMock.mockRejectedValue(Error('something happened')); - await getUninstallTokenHandler(context, request, response); + await getUninstallTokenHandlerWithErrorHandler(context, request, response); expect(response.customError).toHaveBeenCalledWith({ statusCode: 500, diff --git a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts index 2eb9a83456845..165e271af9a06 100644 --- a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts @@ -13,7 +13,6 @@ import type { GetUninstallTokensMetadataRequestSchema, GetUninstallTokenRequestSchema, } from '../../types/rest_spec/uninstall_token'; -import { defaultFleetErrorHandler } from '../../errors'; import type { GetUninstallTokenResponse } from '../../../common/types/rest_spec/uninstall_token'; import { LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../constants'; @@ -21,51 +20,47 @@ export const getUninstallTokensMetadataHandler: FleetRequestHandler< unknown, TypeOf > = async (context, request, response) => { - try { - const [fleetContext, coreContext] = await Promise.all([context.fleet, context.core]); - const uninstallTokenService = fleetContext.uninstallTokenService.asCurrentUser; - - const { page = 1, perPage = 20, policyId, search } = request.query; - - if (policyId && search) { - return response.badRequest({ - body: { - message: 'Query parameters `policyId` and `search` cannot be used at the same time.', - }, - }); - } - - const soClient = coreContext.savedObjects.client; - - const { items: managedPolicies } = await agentPolicyService.list(soClient, { - fields: ['id'], - perPage: SO_SEARCH_LIMIT, - kuery: `${LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE}.is_managed:true`, + const [fleetContext, coreContext] = await Promise.all([context.fleet, context.core]); + const uninstallTokenService = fleetContext.uninstallTokenService.asCurrentUser; + + const { page = 1, perPage = 20, policyId, search } = request.query; + + if (policyId && search) { + return response.badRequest({ + body: { + message: 'Query parameters `policyId` and `search` cannot be used at the same time.', + }, }); + } + + const soClient = coreContext.savedObjects.client; + + const { items: managedPolicies } = await agentPolicyService.list(soClient, { + fields: ['id'], + perPage: SO_SEARCH_LIMIT, + kuery: `${LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE}.is_managed:true`, + }); - const managedPolicyIds = managedPolicies.map((policy) => policy.id); - - let policyIdSearchTerm: string | undefined; - let policyNameSearchTerm: string | undefined; - if (search) { - policyIdSearchTerm = search.trim(); - policyNameSearchTerm = search.trim(); - } else if (policyId) { - policyIdSearchTerm = policyId.trim(); - } - - const body = await uninstallTokenService.getTokenMetadata( - policyIdSearchTerm, - policyNameSearchTerm, - page, - perPage, - managedPolicyIds.length > 0 ? managedPolicyIds : undefined - ); - - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + const managedPolicyIds = managedPolicies.map((policy) => policy.id); + + let policyIdSearchTerm: string | undefined; + let policyNameSearchTerm: string | undefined; + if (search) { + policyIdSearchTerm = search.trim(); + policyNameSearchTerm = search.trim(); + } else if (policyId) { + policyIdSearchTerm = policyId.trim(); } + + const body = await uninstallTokenService.getTokenMetadata( + policyIdSearchTerm, + policyNameSearchTerm, + page, + perPage, + managedPolicyIds.length > 0 ? managedPolicyIds : undefined + ); + + return response.ok({ body }); }; export const getUninstallTokenHandler: FleetRequestHandler< @@ -74,22 +69,18 @@ export const getUninstallTokenHandler: FleetRequestHandler< const [fleetContext] = await Promise.all([context.fleet, context.core]); const uninstallTokenService = fleetContext.uninstallTokenService.asCurrentUser; - try { - const { uninstallTokenId } = request.params; + const { uninstallTokenId } = request.params; - const token = await uninstallTokenService.getToken(uninstallTokenId); + const token = await uninstallTokenService.getToken(uninstallTokenId); - if (token === null) { - return response.notFound({ - body: { message: `Uninstall Token not found with id ${uninstallTokenId}` }, - }); - } - const body: GetUninstallTokenResponse = { - item: token, - }; - - return response.ok({ body }); - } catch (error) { - return defaultFleetErrorHandler({ error, response }); + if (token === null) { + return response.notFound({ + body: { message: `Uninstall Token not found with id ${uninstallTokenId}` }, + }); } + const body: GetUninstallTokenResponse = { + item: token, + }; + + return response.ok({ body }); }; diff --git a/x-pack/plugins/fleet/server/services/security/fleet_router.ts b/x-pack/plugins/fleet/server/services/security/fleet_router.ts index 11a4b084d4807..775fe7e4765e5 100644 --- a/x-pack/plugins/fleet/server/services/security/fleet_router.ts +++ b/x-pack/plugins/fleet/server/services/security/fleet_router.ts @@ -19,6 +19,7 @@ import type { VersionedRouteConfig } from '@kbn/core-http-server'; import { PUBLIC_API_ACCESS } from '../../../common/constants'; import type { FleetRequestHandlerContext } from '../..'; import { getRequestStore } from '../request_store'; +import { defaultFleetErrorHandler } from '../../errors'; import type { FleetVersionedRouteConfig } from './types'; @@ -47,6 +48,26 @@ function withDefaultPublicAccess( } } +export function withDefaultErrorHandler< + TContext extends FleetRequestHandlerContext, + R extends RouteMethod +>( + wrappedHandler: RequestHandler +): RequestHandler { + return async function defaultErrorHandlerWrapper(context, request, response) { + try { + return await wrappedHandler(context, request, response); + } catch (error: any) { + return defaultFleetErrorHandler({ + error, + response, + context, + request, + }); + } + }; +} + export function makeRouterWithFleetAuthz( router: IRouter, logger: Logger @@ -115,14 +136,15 @@ export function makeRouterWithFleetAuthz + handler: withDefaultErrorHandler((handlerContext, handlerRequest, handlerResponse) => routerAuthzWrapper({ context: handlerContext, request: handlerRequest, response: handlerResponse, handler, hasRequiredAuthz, - }), + }) + ), }); }; From 0afae423443ba13c47a263c4cbc270ea09942148 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:15:38 -0500 Subject: [PATCH 50/61] [Cases] [Security Solution] New cases subfeatures, add comments and reopen cases (#194898) ## Summary This pr adds 2 new sub feature permissions to the cases plugin in stack/security/observability, that behave as follows. The first is for controlling the ability to reopen cases. When Cases has the read permission, and the reopen permission is not enabled, users have permissions as before. When enabled, users can move cases from closed to open/in progress, but nothing else. If a user has all and this permission, they can do anything as before, if the option is unselected, they can change case properties, and change a case from open to anything, in progress to anything, but if the case is closed, are unable to reopen it. The 2nd permission is 'Add comment'. When enabled and the user has case read permissions, users can add comments, but not make any other changes to the case. When the user has read and this deselected, read functions as before. When a user has this permission and cases is all, this functions as all. When they have all but this permission is deselected, the user can do everything normally, except add cases comments. ### 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) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [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: Michael Olorunnisola Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../project_roles/security/roles.yml | 24 +- .../serverless_resources/security_roles.json | 14 +- .../features/product_features.ts | 2 +- .../features/src/cases/index.ts | 23 +- .../features/src/cases/types.ts | 1 - .../src/cases/v1_features/kibana_features.ts | 98 +++++ .../{ => v1_features}/kibana_sub_features.ts | 11 +- .../features/src/cases/v1_features/types.ts | 14 + .../{ => v2_features}/kibana_features.ts | 11 +- .../cases/v2_features/kibana_sub_features.ts | 177 +++++++++ .../features/src/constants.ts | 9 + .../features/src/product_features_keys.ts | 2 + .../__snapshots__/cases.test.ts.snap | 14 +- .../feature_privilege_builder/cases.test.ts | 13 +- .../feature_privilege_builder/cases.ts | 10 +- .../cases/common/constants/application.ts | 2 + .../plugins/cases/common/constants/index.ts | 2 + x-pack/plugins/cases/common/index.ts | 4 + x-pack/plugins/cases/common/ui/types.ts | 6 + .../utils/__snapshots__/api_tags.test.ts.snap | 12 +- x-pack/plugins/cases/common/utils/api_tags.ts | 3 +- .../cases/common/utils/capabilities.test.tsx | 7 + .../cases/common/utils/capabilities.ts | 7 + .../client/helpers/can_use_cases.test.ts | 62 +-- .../public/client/helpers/can_use_cases.ts | 14 +- .../client/helpers/capabilities.test.ts | 18 + .../public/client/helpers/capabilities.ts | 17 +- .../common/lib/kibana/__mocks__/index.ts | 2 +- .../public/common/lib/kibana/hooks.test.tsx | 2 +- .../cases/public/common/lib/kibana/hooks.ts | 12 +- .../common/lib/kibana/kibana_react.mock.tsx | 4 +- .../cases/public/common/mock/permissions.ts | 63 ++- .../status/use_should_disable_status.test.tsx | 88 +++++ .../status/use_should_disable_status.tsx | 39 ++ .../actions/status/use_status_action.test.tsx | 46 ++- .../actions/status/use_status_action.tsx | 22 +- .../components/add_comment/index.test.tsx | 28 +- .../public/components/add_comment/index.tsx | 4 +- .../components/all_cases/use_actions.test.tsx | 94 +++++ .../components/all_cases/use_actions.tsx | 20 +- .../all_cases/use_bulk_actions.test.tsx | 73 +++- .../components/all_cases/use_bulk_actions.tsx | 11 +- .../components/all_cases/utility_bar.tsx | 4 +- .../cases/public/components/app/index.tsx | 2 +- .../app/use_available_owners.test.ts | 12 +- .../components/app/use_available_owners.ts | 8 +- .../components/case_action_bar/index.tsx | 10 +- .../status_context_menu.test.tsx | 84 +++- .../case_action_bar/status_context_menu.tsx | 26 +- .../public/components/cases_context/index.tsx | 4 + .../public/components/files/add_file.test.tsx | 14 +- .../public/components/files/add_file.tsx | 2 +- .../components/recent_cases/index.test.tsx | 4 +- .../public/components/user_actions/index.tsx | 8 +- .../use_user_permissions.test.tsx | 259 ++++++++++++ .../user_actions/use_user_permissions.tsx | 38 ++ .../public/containers/use_get_cases.test.tsx | 4 +- x-pack/plugins/cases/public/mocks.ts | 2 + .../__snapshots__/audit_logger.test.ts.snap | 84 ++++ .../__snapshots__/authorization.test.ts.snap | 150 +++++++ .../server/authorization/audit_logger.ts | 7 +- .../authorization/authorization.test.ts | 76 ++++ .../server/authorization/authorization.ts | 42 +- .../cases/server/authorization/index.ts | 10 +- .../cases/server/authorization/types.ts | 3 +- .../server/client/cases/bulk_update.test.ts | 133 ++++++- .../cases/server/client/cases/bulk_update.ts | 23 +- .../server/connectors/cases/index.test.ts | 4 + .../server/connectors/cases/utils.test.ts | 1 + .../cases/server/connectors/cases/utils.ts | 3 +- .../cases/server/features/constants.ts | 18 + x-pack/plugins/cases/server/features/index.ts | 15 + .../server/{features.ts => features/v1.ts} | 48 ++- x-pack/plugins/cases/server/features/v2.ts | 195 +++++++++ x-pack/plugins/cases/server/plugin.ts | 8 +- .../common/feature_kibana_privileges.ts | 21 + .../__snapshots__/oss_features.test.ts.snap | 12 + .../feature_privilege_iterator.test.ts | 52 +++ .../feature_privilege_iterator.ts | 8 + .../plugins/features/server/feature_schema.ts | 2 + .../register_alerts_table_configuration.tsx | 2 +- .../header/add_to_case_action.test.tsx | 2 + .../observability/common/index.ts | 2 + .../pages/alerts/components/alert_actions.tsx | 2 +- .../pages/cases/components/cases.stories.tsx | 4 + .../observability/server/features/cases_v1.ts | 151 +++++++ .../observability/server/features/cases_v2.ts | 181 +++++++++ .../observability/server/plugin.ts | 113 +----- .../observability_shared/common/index.ts | 2 +- .../public/utils/cases_permissions.ts | 4 + .../roles/elasticsearch_role.test.ts | 4 +- .../security_solution/common/constants.ts | 2 +- .../common/test/ess_roles.json | 6 +- .../actions/take_action/index.tsx | 4 +- .../public/cases_test_utils.ts | 14 + .../use_add_to_existing_case.tsx | 2 +- .../use_add_to_new_case.tsx | 2 +- .../public/common/links/links.test.tsx | 20 +- .../alert_context_menu.test.tsx | 2 + .../use_add_to_case_actions.tsx | 12 +- .../public/management/cypress/tasks/common.ts | 2 +- .../public/overview/pages/data_quality.tsx | 4 +- .../security_solution/public/plugin.tsx | 2 +- .../components/modal/header/index.test.tsx | 2 +- .../components/modal/header/index.tsx | 2 +- .../endpoint_operations_analyst.ts | 2 +- .../without_response_actions_role.ts | 2 +- .../lib/product_features_service/mocks.ts | 5 + .../product_features_service.test.ts | 5 +- .../product_features_service.ts | 19 + .../components/add_to_existing_case.test.tsx | 6 +- .../cases/components/add_to_new_case.test.tsx | 6 +- .../cases/hooks/use_case_permission.test.tsx | 6 +- .../cases/hooks/use_case_permission.ts | 2 +- .../apis/cases/common/roles.ts | 78 ++++ .../apis/cases/common/users.ts | 24 ++ .../api_integration/apis/cases/privileges.ts | 70 ++++ .../apis/features/features/features.ts | 12 +- .../apis/security/privileges.ts | 30 ++ .../apis/security/privileges_basic.ts | 33 ++ .../security_solution/cases_privileges.ts | 4 +- .../common/lib/api/case.ts | 37 +- .../common/lib/authentication/roles.ts | 200 ++++------ .../common/lib/authentication/users.ts | 24 ++ .../security_solution/server/plugin.ts | 46 +++ .../trial/create_comment_sub_privilege.ts | 370 ++++++++++++++++++ .../tests/trial/delete_sub_privilege.ts | 3 +- .../security_and_spaces/tests/trial/index.ts | 1 + .../functional/services/ml/security_common.ts | 4 +- .../services/observability/users.ts | 2 +- .../apps/cases/common/roles.ts | 6 +- .../plugins/cases/public/application.tsx | 2 + .../observability_security.ts | 4 +- .../observability/pages/alerts/add_to_case.ts | 4 +- .../observability/pages/cases/case_details.ts | 2 +- .../tests/features/deprecated_features.ts | 3 + .../e2e/investigations/timelines/export.cy.ts | 3 +- .../cypress/tasks/privileges.ts | 4 + .../common/suites/create.ts | 2 + .../common/suites/get.ts | 2 + .../common/suites/get_all.ts | 2 + .../spaces_only/telemetry/telemetry.ts | 3 + x-pack/test/tsconfig.json | 2 +- .../lib/security/default_http_headers.ts | 1 + .../project_controller_security_roles.yml | 2 + 145 files changed, 3541 insertions(+), 516 deletions(-) create mode 100644 x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.ts rename x-pack/packages/security-solution/features/src/cases/{ => v1_features}/kibana_sub_features.ts (85%) create mode 100644 x-pack/packages/security-solution/features/src/cases/v1_features/types.ts rename x-pack/packages/security-solution/features/src/cases/{ => v2_features}/kibana_features.ts (84%) create mode 100644 x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts create mode 100644 x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx create mode 100644 x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx create mode 100644 x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap create mode 100644 x-pack/plugins/cases/server/features/constants.ts create mode 100644 x-pack/plugins/cases/server/features/index.ts rename x-pack/plugins/cases/server/{features.ts => features/v1.ts} (67%) create mode 100644 x-pack/plugins/cases/server/features/v2.ts create mode 100644 x-pack/plugins/observability_solution/observability/server/features/cases_v1.ts create mode 100644 x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/trial/create_comment_sub_privilege.ts diff --git a/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml b/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml index 5c8446123a4fb..07016d0f9fd8d 100644 --- a/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml +++ b/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml @@ -46,7 +46,7 @@ viewer: - feature_siem.read - feature_siem.read_alerts - feature_siem.endpoint_list_read - - feature_securitySolutionCases.read + - feature_securitySolutionCasesV2.read - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -126,7 +126,7 @@ editor: - feature_siem.process_operations_all - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -175,7 +175,7 @@ t1_analyst: - feature_siem.read - feature_siem.read_alerts - feature_siem.endpoint_list_read - - feature_securitySolutionCases.read + - feature_securitySolutionCasesV2.read - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -230,7 +230,7 @@ t2_analyst: - feature_siem.read - feature_siem.read_alerts - feature_siem.endpoint_list_read - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -300,7 +300,7 @@ t3_analyst: - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all - feature_siem.scan_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -362,7 +362,7 @@ threat_intelligence_analyst: - feature_siem.all - feature_siem.endpoint_list_read - feature_siem.blocklist_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -430,7 +430,7 @@ rule_author: - feature_siem.host_isolation_exceptions_read - feature_siem.blocklist_all # Elastic Defend Policy Management - feature_siem.actions_log_management_read - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -502,7 +502,7 @@ soc_manager: - feature_siem.file_operations_all - feature_siem.execute_operations_all - feature_siem.scan_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -562,7 +562,7 @@ detections_admin: - feature_siem.all - feature_siem.read_alerts - feature_siem.crud_alerts - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -621,7 +621,7 @@ platform_engineer: - feature_siem.host_isolation_exceptions_all - feature_siem.blocklist_all # Elastic Defend Policy Management - feature_siem.actions_log_management_read - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -694,7 +694,7 @@ endpoint_operations_analyst: - feature_siem.file_operations_all - feature_siem.execute_operations_all - feature_siem.scan_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -769,7 +769,7 @@ endpoint_policy_manager: - feature_siem.event_filters_all - feature_siem.host_isolation_exceptions_all - feature_siem.blocklist_all # Elastic Defend Policy Management - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all diff --git a/packages/kbn-es/src/serverless_resources/security_roles.json b/packages/kbn-es/src/serverless_resources/security_roles.json index 75106ba041d60..424cb898a4f96 100644 --- a/packages/kbn-es/src/serverless_resources/security_roles.json +++ b/packages/kbn-es/src/serverless_resources/security_roles.json @@ -35,7 +35,7 @@ "siem": ["read", "read_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["read"], + "securitySolutionCasesV2": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, @@ -82,7 +82,7 @@ "siem": ["read", "read_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["read"], + "securitySolutionCasesV2": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, @@ -150,7 +150,7 @@ "actions_log_management_all", "file_operations_all" ], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], "actions": ["read"], @@ -210,7 +210,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["read"], "builtInAlerts": ["all"] }, @@ -263,7 +263,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["all"], "builtInAlerts": ["all"] }, @@ -311,7 +311,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["read"], "builtInAlerts": ["all"], "dev_tools": ["all"] @@ -366,7 +366,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["all"], "builtInAlerts": ["all"] }, diff --git a/x-pack/packages/security-solution/features/product_features.ts b/x-pack/packages/security-solution/features/product_features.ts index b2c524ff6de1d..67d61f21fae5e 100644 --- a/x-pack/packages/security-solution/features/product_features.ts +++ b/x-pack/packages/security-solution/features/product_features.ts @@ -6,6 +6,6 @@ */ export { getSecurityFeature } from './src/security'; -export { getCasesFeature } from './src/cases'; +export { getCasesFeature, getCasesV2Feature } from './src/cases'; export { getAssistantFeature } from './src/assistant'; export { getAttackDiscoveryFeature } from './src/attack_discovery'; diff --git a/x-pack/packages/security-solution/features/src/cases/index.ts b/x-pack/packages/security-solution/features/src/cases/index.ts index 1dcb33d9c3be3..17e5110538b37 100644 --- a/x-pack/packages/security-solution/features/src/cases/index.ts +++ b/x-pack/packages/security-solution/features/src/cases/index.ts @@ -6,10 +6,21 @@ */ import type { CasesSubFeatureId } from '../product_features_keys'; import type { ProductFeatureParams } from '../types'; -import { getCasesBaseKibanaFeature } from './kibana_features'; -import { getCasesBaseKibanaSubFeatureIds, getCasesSubFeaturesMap } from './kibana_sub_features'; +import { getCasesBaseKibanaFeature } from './v1_features/kibana_features'; +import { + getCasesBaseKibanaSubFeatureIds, + getCasesSubFeaturesMap, +} from './v1_features/kibana_sub_features'; import type { CasesFeatureParams } from './types'; +import { getCasesBaseKibanaFeatureV2 } from './v2_features/kibana_features'; +import { + getCasesBaseKibanaSubFeatureIdsV2, + getCasesSubFeaturesMapV2, +} from './v2_features/kibana_sub_features'; +/** + * @deprecated Use getCasesV2Feature instead + */ export const getCasesFeature = ( params: CasesFeatureParams ): ProductFeatureParams => ({ @@ -17,3 +28,11 @@ export const getCasesFeature = ( baseKibanaSubFeatureIds: getCasesBaseKibanaSubFeatureIds(), subFeaturesMap: getCasesSubFeaturesMap(params), }); + +export const getCasesV2Feature = ( + params: CasesFeatureParams +): ProductFeatureParams => ({ + baseKibanaFeature: getCasesBaseKibanaFeatureV2(params), + baseKibanaSubFeatureIds: getCasesBaseKibanaSubFeatureIdsV2(), + subFeaturesMap: getCasesSubFeaturesMapV2(params), +}); diff --git a/x-pack/packages/security-solution/features/src/cases/types.ts b/x-pack/packages/security-solution/features/src/cases/types.ts index a87a1d787d7c0..17fb10fdd64ee 100644 --- a/x-pack/packages/security-solution/features/src/cases/types.ts +++ b/x-pack/packages/security-solution/features/src/cases/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { CasesUiCapabilities, CasesApiTags } from '@kbn/cases-plugin/common'; import type { ProductFeatureCasesKey, CasesSubFeatureId } from '../product_features_keys'; import type { ProductFeatureKibanaConfig } from '../types'; diff --git a/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.ts b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.ts new file mode 100644 index 0000000000000..db442d894363a --- /dev/null +++ b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.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 { i18n } from '@kbn/i18n'; + +import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { KibanaFeatureScope } from '@kbn/features-plugin/common'; +import type { BaseKibanaFeatureConfig } from '../../types'; +import { APP_ID, CASES_FEATURE_ID, CASES_FEATURE_ID_V2 } from '../../constants'; +import type { CasesFeatureParams } from '../types'; + +/** + * @deprecated Use getCasesBaseKibanaFeatureV2 instead + */ +export const getCasesBaseKibanaFeature = ({ + uiCapabilities, + apiTags, + savedObjects, +}: CasesFeatureParams): BaseKibanaFeatureConfig => { + return { + deprecated: { + notice: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCase.deprecationMessage', + { + defaultMessage: + 'The {currentId} permissions are deprecated, please see {casesFeatureIdV2}.', + values: { + currentId: CASES_FEATURE_ID, + casesFeatureIdV2: CASES_FEATURE_ID_V2, + }, + } + ), + }, + id: CASES_FEATURE_ID, + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCaseTitleDeprecated', + { + defaultMessage: 'Cases (Deprecated)', + } + ), + order: 1100, + category: DEFAULT_APP_CATEGORIES.security, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: [APP_ID], + privileges: { + all: { + api: [...apiTags.all, ...apiTags.createComment], + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: { + create: [APP_ID], + read: [APP_ID], + update: [APP_ID], + push: [APP_ID], + createComment: [APP_ID], + reopenCase: [APP_ID], + }, + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + ui: uiCapabilities.all, + replacedBy: { + default: [{ feature: CASES_FEATURE_ID_V2, privileges: ['all'] }], + minimal: [ + { + feature: CASES_FEATURE_ID_V2, + privileges: ['minimal_all', 'create_comment', 'case_reopen'], + }, + ], + }, + }, + read: { + api: apiTags.read, + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: { + read: [APP_ID], + }, + savedObject: { + all: [], + read: [...savedObjects.files], + }, + ui: uiCapabilities.read, + replacedBy: { + default: [{ feature: CASES_FEATURE_ID_V2, privileges: ['read'] }], + minimal: [{ feature: CASES_FEATURE_ID_V2, privileges: ['minimal_read'] }], + }, + }, + }, + }; +}; diff --git a/x-pack/packages/security-solution/features/src/cases/kibana_sub_features.ts b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_sub_features.ts similarity index 85% rename from x-pack/packages/security-solution/features/src/cases/kibana_sub_features.ts rename to x-pack/packages/security-solution/features/src/cases/v1_features/kibana_sub_features.ts index 914b23687956b..ade0dbab2bfea 100644 --- a/x-pack/packages/security-solution/features/src/cases/kibana_sub_features.ts +++ b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_sub_features.ts @@ -7,9 +7,9 @@ import { i18n } from '@kbn/i18n'; import type { SubFeatureConfig } from '@kbn/features-plugin/common'; -import { CasesSubFeatureId } from '../product_features_keys'; -import { APP_ID } from '../constants'; -import type { CasesFeatureParams } from './types'; +import { CasesSubFeatureId } from '../../product_features_keys'; +import { APP_ID, CASES_FEATURE_ID_V2 } from '../../constants'; +import type { CasesFeatureParams } from '../types'; /** * Sub-features that will always be available for Security Cases @@ -21,7 +21,8 @@ export const getCasesBaseKibanaSubFeatureIds = (): CasesSubFeatureId[] => [ ]; /** - * Defines all the Security Assistant subFeatures available. + * @deprecated Use getCasesSubFeaturesMapV2 instead + * @description - Defines all the Security Solution Cases available. * The order of the subFeatures is the order they will be displayed */ export const getCasesSubFeaturesMap = ({ @@ -55,6 +56,7 @@ export const getCasesSubFeaturesMap = ({ delete: [APP_ID], }, ui: uiCapabilities.delete, + replacedBy: [{ feature: CASES_FEATURE_ID_V2, privileges: ['cases_delete'] }], }, ], }, @@ -89,6 +91,7 @@ export const getCasesSubFeaturesMap = ({ settings: [APP_ID], }, ui: uiCapabilities.settings, + replacedBy: [{ feature: CASES_FEATURE_ID_V2, privileges: ['cases_settings'] }], }, ], }, diff --git a/x-pack/packages/security-solution/features/src/cases/v1_features/types.ts b/x-pack/packages/security-solution/features/src/cases/v1_features/types.ts new file mode 100644 index 0000000000000..f17f83ddecce8 --- /dev/null +++ b/x-pack/packages/security-solution/features/src/cases/v1_features/types.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. + */ + +import type { ProductFeatureCasesKey, CasesSubFeatureId } from '../../product_features_keys'; +import type { ProductFeatureKibanaConfig } from '../../types'; + +export type DefaultCasesProductFeaturesConfig = Record< + ProductFeatureCasesKey, + ProductFeatureKibanaConfig +>; diff --git a/x-pack/packages/security-solution/features/src/cases/kibana_features.ts b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_features.ts similarity index 84% rename from x-pack/packages/security-solution/features/src/cases/kibana_features.ts rename to x-pack/packages/security-solution/features/src/cases/v2_features/kibana_features.ts index dd49a60328288..c0c025335d054 100644 --- a/x-pack/packages/security-solution/features/src/cases/kibana_features.ts +++ b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_features.ts @@ -9,17 +9,17 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; -import type { BaseKibanaFeatureConfig } from '../types'; -import { APP_ID, CASES_FEATURE_ID } from '../constants'; -import type { CasesFeatureParams } from './types'; +import type { BaseKibanaFeatureConfig } from '../../types'; +import { APP_ID, CASES_FEATURE_ID_V2, CASES_FEATURE_ID } from '../../constants'; +import type { CasesFeatureParams } from '../types'; -export const getCasesBaseKibanaFeature = ({ +export const getCasesBaseKibanaFeatureV2 = ({ uiCapabilities, apiTags, savedObjects, }: CasesFeatureParams): BaseKibanaFeatureConfig => { return { - id: CASES_FEATURE_ID, + id: CASES_FEATURE_ID_V2, name: i18n.translate( 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCaseTitle', { @@ -41,6 +41,7 @@ export const getCasesBaseKibanaFeature = ({ create: [APP_ID], read: [APP_ID], update: [APP_ID], + push: [APP_ID], }, savedObject: { all: [...savedObjects.files], diff --git a/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts new file mode 100644 index 0000000000000..59aeb866039d4 --- /dev/null +++ b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts @@ -0,0 +1,177 @@ +/* + * 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 { SubFeatureConfig } from '@kbn/features-plugin/common'; +import { CasesSubFeatureId } from '../../product_features_keys'; +import { APP_ID } from '../../constants'; +import type { CasesFeatureParams } from '../types'; + +/** + * Sub-features that will always be available for Security Cases + * regardless of the product type. + */ +export const getCasesBaseKibanaSubFeatureIdsV2 = (): CasesSubFeatureId[] => [ + CasesSubFeatureId.deleteCases, + CasesSubFeatureId.casesSettings, + CasesSubFeatureId.createComment, + CasesSubFeatureId.reopenCase, +]; + +/** + * Defines all the Security Solution Cases subFeatures available. + * The order of the subFeatures is the order they will be displayed + */ +export const getCasesSubFeaturesMapV2 = ({ + uiCapabilities, + apiTags, + savedObjects, +}: CasesFeatureParams) => { + const deleteCasesSubFeature: SubFeatureConfig = { + name: i18n.translate('securitySolutionPackages.features.featureRegistry.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.delete, + id: 'cases_delete', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.deleteSubFeatureDetails', + { + defaultMessage: 'Delete cases and comments', + } + ), + includeIn: 'all', + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + cases: { + delete: [APP_ID], + }, + ui: uiCapabilities.delete, + }, + ], + }, + ], + }; + + const casesSettingsCasesSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.casesSettingsSubFeatureName', + { + defaultMessage: 'Case settings', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cases_settings', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.casesSettingsSubFeatureDetails', + { + defaultMessage: 'Edit case settings', + } + ), + includeIn: 'all', + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + cases: { + settings: [APP_ID], + }, + ui: uiCapabilities.settings, + }, + ], + }, + ], + }; + + /* The below sub features were newly added in v2 (8.17) */ + + const casesAddCommentsCasesSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.addCommentsSubFeatureName', + { + defaultMessage: 'Create comments & attachments', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.createComment, + id: 'create_comment', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.addCommentsSubFeatureDetails', + { + defaultMessage: 'Add comments to cases', + } + ), + includeIn: 'all', + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + cases: { + createComment: [APP_ID], + }, + ui: uiCapabilities.createComment, + }, + ], + }, + ], + }; + const casesreopenCaseSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.reopenCaseSubFeatureName', + { + defaultMessage: 'Re-open', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'case_reopen', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.reopenCaseSubFeatureDetails', + { + defaultMessage: 'Re-open closed cases', + } + ), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + cases: { + reopenCase: [APP_ID], + }, + ui: uiCapabilities.reopenCase, + }, + ], + }, + ], + }; + + return new Map([ + [CasesSubFeatureId.deleteCases, deleteCasesSubFeature], + [CasesSubFeatureId.casesSettings, casesSettingsCasesSubFeature], + /* The below sub features were newly added in v2 (8.17) */ + [CasesSubFeatureId.createComment, casesAddCommentsCasesSubFeature], + [CasesSubFeatureId.reopenCase, casesreopenCaseSubFeature], + ]); +}; diff --git a/x-pack/packages/security-solution/features/src/constants.ts b/x-pack/packages/security-solution/features/src/constants.ts index 5027a7c8d393b..c6acab28c4860 100644 --- a/x-pack/packages/security-solution/features/src/constants.ts +++ b/x-pack/packages/security-solution/features/src/constants.ts @@ -9,7 +9,16 @@ export const APP_ID = 'securitySolution' as const; export const SERVER_APP_ID = 'siem' as const; +/** + * @deprecated deprecated in 8.17. Use CASE_FEATURE_ID_V2 instead + */ export const CASES_FEATURE_ID = 'securitySolutionCases' as const; + +// New version created in 8.17 to adopt the roles migration changes +export const CASES_FEATURE_ID_V2 = 'securitySolutionCasesV2' as const; + +export const SECURITY_SOLUTION_CASES_APP_ID = 'securitySolutionCases' as const; + export const ASSISTANT_FEATURE_ID = 'securitySolutionAssistant' as const; export const ATTACK_DISCOVERY_FEATURE_ID = 'securitySolutionAttackDiscovery' as const; diff --git a/x-pack/packages/security-solution/features/src/product_features_keys.ts b/x-pack/packages/security-solution/features/src/product_features_keys.ts index e72e669716c59..42a190b189234 100644 --- a/x-pack/packages/security-solution/features/src/product_features_keys.ts +++ b/x-pack/packages/security-solution/features/src/product_features_keys.ts @@ -148,6 +148,8 @@ export enum SecuritySubFeatureId { export enum CasesSubFeatureId { deleteCases = 'deleteCasesSubFeature', casesSettings = 'casesSettingsSubFeature', + createComment = 'createCommentSubFeature', + reopenCase = 'reopenCaseSubFeature', } /** Sub-features IDs for Security Assistant */ diff --git a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap index 1874a17515e19..2997187697c40 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap +++ b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap @@ -4,7 +4,6 @@ exports[`cases feature_privilege_builder within feature grants all privileges un Array [ "cases:observability/pushCase", "cases:observability/createCase", - "cases:observability/createComment", "cases:observability/getCase", "cases:observability/getComment", "cases:observability/getTags", @@ -17,12 +16,19 @@ Array [ "cases:observability/deleteComment", "cases:observability/createConfiguration", "cases:observability/updateConfiguration", + "cases:observability/createComment", + "cases:observability/reopenCase", ] `; exports[`cases feature_privilege_builder within feature grants create privileges under feature with id securitySolution 1`] = ` Array [ "cases:securitySolution/createCase", +] +`; + +exports[`cases feature_privilege_builder within feature grants createComment privileges under feature with id securitySolution 1`] = ` +Array [ "cases:securitySolution/createComment", ] `; @@ -51,6 +57,12 @@ Array [ ] `; +exports[`cases feature_privilege_builder within feature grants reopenCase privileges under feature with id observability 1`] = ` +Array [ + "cases:observability/reopenCase", +] +`; + exports[`cases feature_privilege_builder within feature grants settings privileges under feature with id observability 1`] = ` Array [ "cases:observability/createConfiguration", diff --git a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts index ad0563ef7a827..eae3bbc942e34 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts @@ -48,6 +48,8 @@ describe(`cases`, () => { ['update', 'observability'], ['delete', 'securitySolution'], ['settings', 'observability'], + ['createComment', 'securitySolution'], + ['reopenCase', 'observability'], ])('grants %s privileges under feature with id %s', (operation, featureID) => { const actions = new Actions(); const casesFeaturePrivilege = new FeaturePrivilegeCasesBuilder(actions); @@ -89,6 +91,8 @@ describe(`cases`, () => { delete: ['security'], read: ['obs'], settings: ['security'], + createComment: ['security'], + reopenCase: ['security'], }, savedObject: { all: [], @@ -112,7 +116,6 @@ describe(`cases`, () => { Array [ "cases:security/pushCase", "cases:security/createCase", - "cases:security/createComment", "cases:security/getCase", "cases:security/getComment", "cases:security/getTags", @@ -125,6 +128,8 @@ describe(`cases`, () => { "cases:security/deleteComment", "cases:security/createConfiguration", "cases:security/updateConfiguration", + "cases:security/createComment", + "cases:security/reopenCase", "cases:obs/getCase", "cases:obs/getComment", "cases:obs/getTags", @@ -168,7 +173,6 @@ describe(`cases`, () => { Array [ "cases:security/pushCase", "cases:security/createCase", - "cases:security/createComment", "cases:security/getCase", "cases:security/getComment", "cases:security/getTags", @@ -181,9 +185,10 @@ describe(`cases`, () => { "cases:security/deleteComment", "cases:security/createConfiguration", "cases:security/updateConfiguration", + "cases:security/createComment", + "cases:security/reopenCase", "cases:other-security/pushCase", "cases:other-security/createCase", - "cases:other-security/createComment", "cases:other-security/getCase", "cases:other-security/getComment", "cases:other-security/getTags", @@ -196,6 +201,8 @@ describe(`cases`, () => { "cases:other-security/deleteComment", "cases:other-security/createConfiguration", "cases:other-security/updateConfiguration", + "cases:other-security/createComment", + "cases:other-security/reopenCase", "cases:obs/getCase", "cases:obs/getComment", "cases:obs/getTags", diff --git a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts index 7672e1920fd4b..3cf293b935b36 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts @@ -22,7 +22,7 @@ export type CasesSupportedOperations = (typeof allOperations)[number]; */ const pushOperations = ['pushCase'] as const; -const createOperations = ['createCase', 'createComment'] as const; +const createOperations = ['createCase'] as const; const readOperations = [ 'getCase', 'getComment', @@ -31,9 +31,12 @@ const readOperations = [ 'getUserActions', 'findConfigurations', ] as const; +// Update operations do not currently include the ability to re-open a case const updateOperations = ['updateCase', 'updateComment'] as const; const deleteOperations = ['deleteCase', 'deleteComment'] as const; const settingsOperations = ['createConfiguration', 'updateConfiguration'] as const; +const createCommentOperations = ['createComment'] as const; +const reopenOperations = ['reopenCase'] as const; const allOperations = [ ...pushOperations, ...createOperations, @@ -41,6 +44,8 @@ const allOperations = [ ...updateOperations, ...deleteOperations, ...settingsOperations, + ...createCommentOperations, + ...reopenOperations, ] as const; export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { @@ -56,7 +61,6 @@ export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { operations.map((operation) => this.actions.cases.get(owner, operation)) ); }; - return uniq([ ...getCasesPrivilege(allOperations, privilegeDefinition.cases?.all), ...getCasesPrivilege(pushOperations, privilegeDefinition.cases?.push), @@ -65,6 +69,8 @@ export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { ...getCasesPrivilege(updateOperations, privilegeDefinition.cases?.update), ...getCasesPrivilege(deleteOperations, privilegeDefinition.cases?.delete), ...getCasesPrivilege(settingsOperations, privilegeDefinition.cases?.settings), + ...getCasesPrivilege(createCommentOperations, privilegeDefinition.cases?.createComment), + ...getCasesPrivilege(reopenOperations, privilegeDefinition.cases?.reopenCase), ]); } } diff --git a/x-pack/plugins/cases/common/constants/application.ts b/x-pack/plugins/cases/common/constants/application.ts index 4b43a17708ab6..01bbea157e7d2 100644 --- a/x-pack/plugins/cases/common/constants/application.ts +++ b/x-pack/plugins/cases/common/constants/application.ts @@ -12,7 +12,9 @@ import { CASE_VIEW_PAGE_TABS } from '../types'; */ export const APP_ID = 'cases' as const; +/** @deprecated Please use FEATURE_ID_V2 instead */ export const FEATURE_ID = 'generalCases' as const; +export const FEATURE_ID_V2 = 'generalCasesV2' as const; export const APP_OWNER = 'cases' as const; export const APP_PATH = '/app/management/insightsAndAlerting/cases' as const; export const CASES_CREATE_PATH = '/create' as const; diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index aa3855807cea2..1fee73f8608c8 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -174,6 +174,8 @@ export const DELETE_CASES_CAPABILITY = 'delete_cases' as const; export const PUSH_CASES_CAPABILITY = 'push_cases' as const; export const CASES_SETTINGS_CAPABILITY = 'cases_settings' as const; export const CASES_CONNECTORS_CAPABILITY = 'cases_connectors' as const; +export const CASES_REOPEN_CAPABILITY = 'case_reopen' as const; +export const CREATE_COMMENT_CAPABILITY = 'create_comment' as const; /** * Cases API Tags diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts index ead81710c451d..8e3b2644ee01a 100644 --- a/x-pack/plugins/cases/common/index.ts +++ b/x-pack/plugins/cases/common/index.ts @@ -18,6 +18,7 @@ export type { CasesBulkGetResponse, CasePostRequest, + CasePatchRequest, GetRelatedCasesByAlertResponse, UserActionFindResponse, } from './types/api'; @@ -38,6 +39,7 @@ export { CaseSeverity } from './types/domain'; export { APP_ID, FEATURE_ID, + FEATURE_ID_V2, CASES_URL, SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, @@ -55,6 +57,8 @@ export { CASES_CONNECTORS_CAPABILITY, GET_CONNECTORS_CONFIGURE_API_TAG, CASES_SETTINGS_CAPABILITY, + CREATE_COMMENT_CAPABILITY, + CASES_REOPEN_CAPABILITY, } from './constants'; export type { AttachmentAttributes } from './types/domain'; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 6d75b30dd119d..99c92e0dbb55b 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -11,6 +11,8 @@ import type { DELETE_CASES_CAPABILITY, READ_CASES_CAPABILITY, UPDATE_CASES_CAPABILITY, + CREATE_COMMENT_CAPABILITY, + CASES_REOPEN_CAPABILITY, } from '..'; import type { CASES_CONNECTORS_CAPABILITY, @@ -305,6 +307,8 @@ export interface CasesPermissions { push: boolean; connectors: boolean; settings: boolean; + reopenCase: boolean; + createComment: boolean; } export interface CasesCapabilities { @@ -315,4 +319,6 @@ export interface CasesCapabilities { [PUSH_CASES_CAPABILITY]: boolean; [CASES_CONNECTORS_CAPABILITY]: boolean; [CASES_SETTINGS_CAPABILITY]: boolean; + [CREATE_COMMENT_CAPABILITY]: boolean; + [CASES_REOPEN_CAPABILITY]: boolean; } diff --git a/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap b/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap index 9cca596cc84d8..10fdb6da9673a 100644 --- a/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap +++ b/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap @@ -6,9 +6,11 @@ Object { "casesSuggestUserProfiles", "bulkGetUserProfiles", "casesGetConnectorsConfigure", - "casesFilesCasesCreate", "casesFilesCasesRead", ], + "createComment": Array [ + "casesFilesCasesCreate", + ], "delete": Array [ "casesFilesCasesDelete", ], @@ -27,9 +29,11 @@ Object { "casesSuggestUserProfiles", "bulkGetUserProfiles", "casesGetConnectorsConfigure", - "observabilityFilesCasesCreate", "observabilityFilesCasesRead", ], + "createComment": Array [ + "observabilityFilesCasesCreate", + ], "delete": Array [ "observabilityFilesCasesDelete", ], @@ -48,9 +52,11 @@ Object { "casesSuggestUserProfiles", "bulkGetUserProfiles", "casesGetConnectorsConfigure", - "securitySolutionFilesCasesCreate", "securitySolutionFilesCasesRead", ], + "createComment": Array [ + "securitySolutionFilesCasesCreate", + ], "delete": Array [ "securitySolutionFilesCasesDelete", ], diff --git a/x-pack/plugins/cases/common/utils/api_tags.ts b/x-pack/plugins/cases/common/utils/api_tags.ts index 3fbad714e55f9..e4750540c5b5e 100644 --- a/x-pack/plugins/cases/common/utils/api_tags.ts +++ b/x-pack/plugins/cases/common/utils/api_tags.ts @@ -18,6 +18,7 @@ export interface CasesApiTags { all: readonly string[]; read: readonly string[]; delete: readonly string[]; + createComment: readonly string[]; } export const getApiTags = (owner: Owner): CasesApiTags => { @@ -30,7 +31,6 @@ export const getApiTags = (owner: Owner): CasesApiTags => { SUGGEST_USER_PROFILES_API_TAG, BULK_GET_USER_PROFILES_API_TAG, GET_CONNECTORS_CONFIGURE_API_TAG, - create, read, ] as const, read: [ @@ -40,5 +40,6 @@ export const getApiTags = (owner: Owner): CasesApiTags => { read, ] as const, delete: [deleteTag] as const, + createComment: [create] as const, }; }; diff --git a/x-pack/plugins/cases/common/utils/capabilities.test.tsx b/x-pack/plugins/cases/common/utils/capabilities.test.tsx index 07b82ea0d0e8f..11f74af8e02d8 100644 --- a/x-pack/plugins/cases/common/utils/capabilities.test.tsx +++ b/x-pack/plugins/cases/common/utils/capabilities.test.tsx @@ -17,6 +17,10 @@ describe('createUICapabilities', () => { "update_cases", "push_cases", "cases_connectors", + "cases_settings", + ], + "createComment": Array [ + "create_comment", ], "delete": Array [ "delete_cases", @@ -25,6 +29,9 @@ describe('createUICapabilities', () => { "read_cases", "cases_connectors", ], + "reopenCase": Array [ + "case_reopen", + ], "settings": Array [ "cases_settings", ], diff --git a/x-pack/plugins/cases/common/utils/capabilities.ts b/x-pack/plugins/cases/common/utils/capabilities.ts index 6b33dd8c8dceb..6897dc6bae774 100644 --- a/x-pack/plugins/cases/common/utils/capabilities.ts +++ b/x-pack/plugins/cases/common/utils/capabilities.ts @@ -13,6 +13,8 @@ import { READ_CASES_CAPABILITY, UPDATE_CASES_CAPABILITY, CASES_SETTINGS_CAPABILITY, + CASES_REOPEN_CAPABILITY, + CREATE_COMMENT_CAPABILITY, } from '../constants'; export interface CasesUiCapabilities { @@ -20,6 +22,8 @@ export interface CasesUiCapabilities { read: readonly string[]; delete: readonly string[]; settings: readonly string[]; + reopenCase: readonly string[]; + createComment: readonly string[]; } /** * Return the UI capabilities for each type of operation. These strings must match the values defined in the UI @@ -32,8 +36,11 @@ export const createUICapabilities = (): CasesUiCapabilities => ({ UPDATE_CASES_CAPABILITY, PUSH_CASES_CAPABILITY, CASES_CONNECTORS_CAPABILITY, + CASES_SETTINGS_CAPABILITY, ] as const, read: [READ_CASES_CAPABILITY, CASES_CONNECTORS_CAPABILITY] as const, delete: [DELETE_CASES_CAPABILITY] as const, settings: [CASES_SETTINGS_CAPABILITY] as const, + reopenCase: [CASES_REOPEN_CAPABILITY] as const, + createComment: [CREATE_COMMENT_CAPABILITY] as const, }); diff --git a/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts b/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts index 5b82919523f36..69eca9d064602 100644 --- a/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts +++ b/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts @@ -20,67 +20,67 @@ import { canUseCases } from './can_use_cases'; type CasesCapabilities = Pick< ApplicationStart['capabilities'], - 'securitySolutionCases' | 'observabilityCases' | 'generalCases' + 'securitySolutionCasesV2' | 'observabilityCasesV2' | 'generalCasesV2' >; const hasAll: CasesCapabilities = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: allCasesCapabilities(), - generalCases: allCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: allCasesCapabilities(), + generalCasesV2: allCasesCapabilities(), }; const hasNone: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurity: CasesCapabilities = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasObservability: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: allCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: allCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasObservabilityWriteTrue: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: writeCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: writeCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityWriteTrue: CasesCapabilities = { - securitySolutionCases: writeCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: writeCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasObservabilityReadTrue: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: readCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: readCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityReadTrue: CasesCapabilities = { - securitySolutionCases: readCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: readCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityWriteAndObservabilityRead: CasesCapabilities = { - securitySolutionCases: writeCasesCapabilities(), - observabilityCases: readCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: writeCasesCapabilities(), + observabilityCasesV2: readCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityConnectors: CasesCapabilities = { - securitySolutionCases: readCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: readCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; describe('canUseCases', () => { diff --git a/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts b/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts index 90b0d3b18908f..3e318132f8adf 100644 --- a/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts +++ b/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts @@ -7,7 +7,7 @@ import type { ApplicationStart } from '@kbn/core/public'; import { - FEATURE_ID, + FEATURE_ID_V2, GENERAL_CASES_OWNER, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, @@ -42,6 +42,8 @@ export const canUseCases = acc.push = acc.push || userCapabilitiesForOwner.push; acc.connectors = acc.connectors || userCapabilitiesForOwner.connectors; acc.settings = acc.settings || userCapabilitiesForOwner.settings; + acc.reopenCase = acc.reopenCase || userCapabilitiesForOwner.reopenCase; + acc.createComment = acc.createComment || userCapabilitiesForOwner.createComment; const allFromAcc = acc.create && @@ -50,7 +52,9 @@ export const canUseCases = acc.delete && acc.push && acc.connectors && - acc.settings; + acc.settings && + acc.reopenCase && + acc.createComment; acc.all = acc.all || userCapabilitiesForOwner.all || allFromAcc; @@ -65,6 +69,8 @@ export const canUseCases = push: false, connectors: false, settings: false, + reopenCase: false, + createComment: false, } ); @@ -75,8 +81,8 @@ export const canUseCases = const getFeatureID = (owner: CasesOwners) => { if (owner === GENERAL_CASES_OWNER) { - return FEATURE_ID; + return FEATURE_ID_V2; } - return `${owner}Cases`; + return `${owner}CasesV2`; }; diff --git a/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts b/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts index ce374243b10b2..ec1b90eee0eb1 100644 --- a/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts +++ b/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts @@ -14,9 +14,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -29,9 +31,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -44,9 +48,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": true, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -68,9 +74,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -83,9 +91,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -107,9 +117,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": true, "create": false, + "createComment": false, "delete": true, "push": true, "read": true, + "reopenCase": false, "settings": false, "update": true, } @@ -132,9 +144,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": true, + "createComment": false, "delete": true, "push": true, "read": true, + "reopenCase": false, "settings": true, "update": true, } @@ -157,9 +171,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": true, "create": true, + "createComment": false, "delete": true, "push": true, "read": true, + "reopenCase": false, "settings": false, "update": true, } @@ -172,9 +188,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": true, "update": false, } diff --git a/x-pack/plugins/cases/public/client/helpers/capabilities.ts b/x-pack/plugins/cases/public/client/helpers/capabilities.ts index 9be5b5f05f646..634cb3188602d 100644 --- a/x-pack/plugins/cases/public/client/helpers/capabilities.ts +++ b/x-pack/plugins/cases/public/client/helpers/capabilities.ts @@ -14,6 +14,8 @@ import { PUSH_CASES_CAPABILITY, READ_CASES_CAPABILITY, UPDATE_CASES_CAPABILITY, + CASES_REOPEN_CAPABILITY, + CREATE_COMMENT_CAPABILITY, } from '../../../common/constants'; export const getUICapabilities = ( @@ -26,8 +28,19 @@ export const getUICapabilities = ( const push = !!featureCapabilities?.[PUSH_CASES_CAPABILITY]; const connectors = !!featureCapabilities?.[CASES_CONNECTORS_CAPABILITY]; const settings = !!featureCapabilities?.[CASES_SETTINGS_CAPABILITY]; + const reopenCase = !!featureCapabilities?.[CASES_REOPEN_CAPABILITY]; + const createComment = !!featureCapabilities?.[CREATE_COMMENT_CAPABILITY]; - const all = create && read && update && deletePriv && push && connectors && settings; + const all = + create && + read && + update && + deletePriv && + push && + connectors && + settings && + reopenCase && + createComment; return { all, @@ -38,5 +51,7 @@ export const getUICapabilities = ( push, connectors, settings, + reopenCase, + createComment, }; }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index 7bf4e71e0717a..5e65dd0933e0e 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -48,7 +48,7 @@ export const useNavigation = jest.fn().mockReturnValue({ export const useApplicationCapabilities = jest.fn().mockReturnValue({ actions: { crud: true, read: true }, - generalCases: { crud: true, read: true }, + generalCasesV2: { crud: true, read: true }, visualize: { crud: true, read: true }, dashboard: { crud: true, read: true }, }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx index 8d0beb130edc6..60b798d37822a 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx @@ -23,7 +23,7 @@ describe('hooks', () => { expect(result.current).toEqual({ actions: { crud: true, read: true }, - generalCases: allCasesPermissions(), + generalCasesV2: allCasesPermissions(), visualize: { crud: true, read: true }, dashboard: { crud: true, read: true }, }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index 3d72e5ca552b9..6a309111ceddb 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -15,7 +15,7 @@ import type { NavigateToAppOptions } from '@kbn/core/public'; import { getUICapabilities } from '../../../client/helpers/capabilities'; import { convertToCamelCase } from '../../../api/utils'; import { - FEATURE_ID, + FEATURE_ID_V2, DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ, } from '../../../../common/constants'; @@ -166,7 +166,7 @@ interface Capabilities { } interface UseApplicationCapabilities { actions: Capabilities; - generalCases: CasesPermissions; + generalCasesV2: CasesPermissions; visualize: Capabilities; dashboard: Capabilities; } @@ -178,13 +178,13 @@ interface UseApplicationCapabilities { export const useApplicationCapabilities = (): UseApplicationCapabilities => { const capabilities = useKibana().services?.application?.capabilities; - const casesCapabilities = capabilities[FEATURE_ID]; + const casesCapabilities = capabilities[FEATURE_ID_V2]; const permissions = getUICapabilities(casesCapabilities); return useMemo( () => ({ actions: { crud: !!capabilities.actions?.save, read: !!capabilities.actions?.show }, - generalCases: { + generalCasesV2: { all: permissions.all, create: permissions.create, read: permissions.read, @@ -193,6 +193,8 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => { push: permissions.push, connectors: permissions.connectors, settings: permissions.settings, + reopenCase: permissions.reopenCase, + createComment: permissions.createComment, }, visualize: { crud: !!capabilities.visualize?.save, read: !!capabilities.visualize?.show }, dashboard: { @@ -215,6 +217,8 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => { permissions.push, permissions.connectors, permissions.settings, + permissions.reopenCase, + permissions.createComment, ] ); }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx index 0223e4648ac93..48ef98c8dffa8 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx @@ -83,7 +83,7 @@ export const createStartServicesMock = ({ license }: StartServiceArgs = {}): Sta services.application.capabilities = { ...services.application.capabilities, actions: { save: true, show: true }, - generalCases: { + generalCasesV2: { create_cases: true, read_cases: true, update_cases: true, @@ -91,6 +91,8 @@ export const createStartServicesMock = ({ license }: StartServiceArgs = {}): Sta push_cases: true, cases_connectors: true, cases_settings: true, + case_reopen: true, + create_comment: true, }, visualize: { save: true, show: true }, dashboard: { show: true, createNew: true }, diff --git a/x-pack/plugins/cases/public/common/mock/permissions.ts b/x-pack/plugins/cases/public/common/mock/permissions.ts index fce274cd7f338..9e08120a8c275 100644 --- a/x-pack/plugins/cases/public/common/mock/permissions.ts +++ b/x-pack/plugins/cases/public/common/mock/permissions.ts @@ -17,6 +17,8 @@ export const noCasesPermissions = () => push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }); export const readCasesPermissions = () => @@ -28,16 +30,52 @@ export const readCasesPermissions = () => push: false, connectors: true, settings: false, + createComment: false, + reopenCase: false, }); export const noCreateCasesPermissions = () => buildCasesPermissions({ create: false }); -export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: false }); +export const noCreateCommentCasesPermissions = () => + buildCasesPermissions({ createComment: false }); +export const noUpdateCasesPermissions = () => + buildCasesPermissions({ update: false, reopenCase: false }); export const noPushCasesPermissions = () => buildCasesPermissions({ push: false }); export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false }); +export const noReopenCasesPermissions = () => buildCasesPermissions({ reopenCase: false }); export const writeCasesPermissions = () => buildCasesPermissions({ read: false }); +export const onlyCreateCommentPermissions = () => + buildCasesPermissions({ + read: false, + create: false, + update: false, + delete: true, + push: false, + createComment: true, + reopenCase: false, + }); export const onlyDeleteCasesPermission = () => - buildCasesPermissions({ read: false, create: false, update: false, delete: true, push: false }); + buildCasesPermissions({ + read: false, + create: false, + update: false, + delete: true, + push: false, + createComment: false, + reopenCase: false, + }); +// In practice, a real life user should never have this configuration, but testing for thoroughness +export const onlyReopenCasesPermission = () => + buildCasesPermissions({ + read: false, + create: false, + update: false, + delete: false, + push: false, + createComment: false, + reopenCase: true, + }); export const noConnectorsCasePermission = () => buildCasesPermissions({ connectors: false }); export const noCasesSettingsPermission = () => buildCasesPermissions({ settings: false }); +export const disabledReopenCasePermission = () => buildCasesPermissions({ reopenCase: false }); export const buildCasesPermissions = (overrides: Partial> = {}) => { const create = overrides.create ?? true; @@ -47,7 +85,18 @@ export const buildCasesPermissions = (overrides: Partial push_cases: false, cases_connectors: false, cases_settings: false, + create_comment: false, + case_reopen: false, }); export const readCasesCapabilities = () => buildCasesCapabilities({ @@ -79,6 +132,8 @@ export const readCasesCapabilities = () => delete_cases: false, push_cases: false, cases_settings: false, + create_comment: false, + case_reopen: false, }); export const writeCasesCapabilities = () => { return buildCasesCapabilities({ @@ -95,5 +150,7 @@ export const buildCasesCapabilities = (overrides?: Partial) = push_cases: overrides?.push_cases ?? true, cases_connectors: overrides?.cases_connectors ?? true, cases_settings: overrides?.cases_settings ?? true, + create_comment: overrides?.create_comment ?? true, + case_reopen: overrides?.case_reopen ?? true, }; }; diff --git a/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx new file mode 100644 index 0000000000000..37957c9fe1f8e --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { CaseStatuses } from '../../../../common/types/domain'; +import { useUserPermissions } from '../../user_actions/use_user_permissions'; +import { useShouldDisableStatus } from './use_should_disable_status'; + +jest.mock('../../user_actions/use_user_permissions'); +const mockUseUserPermissions = useUserPermissions as jest.Mock; + +describe('useShouldDisableStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should disable status when user has no permissions', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: false, + canReopenCase: false, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const cases = [{ status: CaseStatuses.open }]; + expect(result.current(cases)).toBe(true); + }); + + it('should allow status change when user has all permissions', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: true, + canReopenCase: true, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const cases = [{ status: CaseStatuses.open }]; + expect(result.current(cases)).toBe(false); + }); + + it('should only allow reopening when user can only reopen cases', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: false, + canReopenCase: true, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const cases = [{ status: CaseStatuses.closed }, { status: CaseStatuses.open }]; + + expect(result.current(cases)).toBe(false); + + const closedCases = [{ status: CaseStatuses.closed }]; + expect(result.current(closedCases)).toBe(false); + }); + + it('should prevent reopening closed cases when user cannot reopen', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: true, + canReopenCase: false, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const closedCases = [{ status: CaseStatuses.closed }]; + expect(result.current(closedCases)).toBe(true); + + const openCases = [{ status: CaseStatuses.open }]; + expect(result.current(openCases)).toBe(false); + }); + + it('should handle multiple selected cases correctly', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: true, + canReopenCase: false, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const mixedCases = [{ status: CaseStatuses.open }, { status: CaseStatuses.closed }]; + + expect(result.current(mixedCases)).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx new file mode 100644 index 0000000000000..e329a3c8787b7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback } from 'react'; +import type { CasesUI } from '../../../../common'; +import { CaseStatuses } from '../../../../common/types/domain'; + +import { useUserPermissions } from '../../user_actions/use_user_permissions'; + +export const useShouldDisableStatus = () => { + const { canUpdate, canReopenCase } = useUserPermissions(); + + const shouldDisableStatusFn = useCallback( + (selectedCases: Array>) => { + // Read Only + Disabled => Cannot do anything + const missingAllUpdatePermissions = !canUpdate && !canReopenCase; + if (missingAllUpdatePermissions) return true; + + // All + Enabled reopen => can change status at any point in any way + if (canUpdate && canReopenCase) return false; + + const selectedCasesContainsClosed = selectedCases.some( + (theCase) => theCase.status === CaseStatuses.closed + ); + + if (selectedCasesContainsClosed) { + return !canReopenCase; + } else { + return !canUpdate; + } + }, + [canReopenCase, canUpdate] + ); + + return shouldDisableStatusFn; +}; diff --git a/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx b/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx index bb4aef3379aa3..5ad7f9803dd67 100644 --- a/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx @@ -13,7 +13,11 @@ import { useStatusAction } from './use_status_action'; import * as api from '../../../containers/api'; import { basicCase } from '../../../containers/mock'; import { CaseStatuses } from '../../../../common/types/domain'; +import { useUserPermissions } from '../../user_actions/use_user_permissions'; +import { useShouldDisableStatus } from './use_should_disable_status'; +jest.mock('../../user_actions/use_user_permissions'); +jest.mock('./use_should_disable_status'); jest.mock('../../../containers/api'); describe('useStatusAction', () => { @@ -24,6 +28,12 @@ describe('useStatusAction', () => { beforeEach(() => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => false); + + (useUserPermissions as jest.Mock).mockReturnValue({ + canUpdate: true, + canReopenCase: true, + }); }); it('renders an action', async () => { @@ -43,7 +53,7 @@ describe('useStatusAction', () => { Array [ Object { "data-test-subj": "cases-bulk-action-status-open", - "disabled": true, + "disabled": false, "icon": "empty", "key": "cases-bulk-action-status-open", "name": "Open", @@ -172,6 +182,8 @@ describe('useStatusAction', () => { ]; it.each(disabledTests)('disables the status button correctly: %s', async (status, index) => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); + const { result } = renderHook( () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), { @@ -197,4 +209,36 @@ describe('useStatusAction', () => { expect(actions[index].disabled).toBe(true); } ); + + it('respects user permissions when everything is false', () => { + (useUserPermissions as jest.Mock).mockReturnValue({ + canUpdate: false, + canReopenCase: false, + }); + + const { result } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.canUpdateStatus).toBe(false); + }); + + it('respects user permissions when only reopen is true', () => { + (useUserPermissions as jest.Mock).mockReturnValue({ + canUpdate: false, + canReopenCase: true, + }); + + const { result } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.canUpdateStatus).toBe(true); + }); }); diff --git a/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx b/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx index eb00800961085..abbc0535656d3 100644 --- a/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx +++ b/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx @@ -14,7 +14,8 @@ import { CaseStatuses } from '../../../../common/types/domain'; import * as i18n from './translations'; import type { UseActionProps } from '../types'; import { statuses } from '../../status'; -import { useCasesContext } from '../../cases_context/use_cases_context'; +import { useUserPermissions } from '../../user_actions/use_user_permissions'; +import { useShouldDisableStatus } from './use_should_disable_status'; const getStatusToasterMessage = (status: CaseStatuses, cases: CasesUI): string => { const totalCases = cases.length; @@ -35,9 +36,6 @@ interface UseStatusActionProps extends UseActionProps { selectedStatus?: CaseStatuses; } -const shouldDisableStatus = (cases: CasesUI, status: CaseStatuses) => - cases.every((theCase) => theCase.status === status); - export const useStatusAction = ({ onAction, onActionSuccess, @@ -45,10 +43,7 @@ export const useStatusAction = ({ selectedStatus, }: UseStatusActionProps) => { const { mutate: updateCases } = useUpdateCases(); - const { permissions } = useCasesContext(); - const canUpdateStatus = permissions.update; - const isActionDisabled = isDisabled || !canUpdateStatus; - + const { canUpdate, canReopenCase } = useUserPermissions(); const handleUpdateCaseStatus = useCallback( (selectedCases: CasesUI, status: CaseStatuses) => { onAction(); @@ -69,6 +64,8 @@ export const useStatusAction = ({ [onAction, updateCases, onActionSuccess] ); + const shouldDisableStatus = useShouldDisableStatus(); + const getStatusIcon = (status: CaseStatuses): string => selectedStatus && selectedStatus === status ? 'check' : 'empty'; @@ -78,7 +75,7 @@ export const useStatusAction = ({ name: statuses[CaseStatuses.open].label, icon: getStatusIcon(CaseStatuses.open), onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.open), - disabled: isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses.open), + disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-open', key: 'cases-bulk-action-status-open', }, @@ -86,8 +83,7 @@ export const useStatusAction = ({ name: statuses[CaseStatuses['in-progress']].label, icon: getStatusIcon(CaseStatuses['in-progress']), onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses['in-progress']), - disabled: - isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses['in-progress']), + disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-in-progress', key: 'cases-bulk-action-status-in-progress', }, @@ -95,14 +91,14 @@ export const useStatusAction = ({ name: statuses[CaseStatuses.closed].label, icon: getStatusIcon(CaseStatuses.closed), onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.closed), - disabled: isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses.closed), + disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-closed', key: 'cases-bulk-status-action', }, ]; }; - return { getActions, canUpdateStatus }; + return { getActions, canUpdateStatus: canUpdate || canReopenCase }; }; export type UseStatusAction = ReturnType; diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 5664151aa6df0..60fcb320ddfd0 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -10,7 +10,12 @@ import { waitFor, act, fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { noop } from 'lodash/fp'; -import { noCreateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; +import { + onlyCreateCommentPermissions, + noCreateCommentCasesPermissions, + TestProviders, + createAppMockRenderer, +} from '../../common/mock'; import { AttachmentType } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER, MAX_COMMENT_LENGTH } from '../../../common/constants'; @@ -93,19 +98,36 @@ describe('AddComment ', () => { expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled'); }); - it('should hide the component when the user does not have create permissions', () => { + it('should hide the component when the user does not have createComment permissions', () => { createAttachmentsMock.mockImplementation(() => ({ ...defaultResponse, isLoading: true, })); appMockRender.render( - + ); expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-comment-form-wrapper')).not.toBeInTheDocument(); + }); + + it('should show the component when the user does not have create permissions, but has createComment permissions', () => { + createAttachmentsMock.mockImplementation(() => ({ + ...defaultResponse, + isLoading: true, + })); + + appMockRender.render( + + + + ); + + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-comment-form-wrapper')).toBeInTheDocument(); }); it('should post comment on submit click', async () => { diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index c84f799b1c899..11d3b89eb13d2 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -191,8 +191,8 @@ export const AddComment = React.memo( size="xl" /> )} - {permissions.create && ( - + {permissions.createComment && ( + { expect(res.getByTestId(`case-action-popover-button-${basicCase.id}`)).toBeDisabled(); }); }); + + it('shows actions when user only has reopenCase permission and only when case is closed', async () => { + appMockRender = createAppMockRenderer({ + permissions: { + all: false, + read: true, + create: false, + update: false, + delete: false, + reopenCase: true, + push: false, + connectors: true, + settings: false, + createComment: false, + }, + }); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).not.toBe(null); + const caseWithClosedStatus = { ...basicCase, status: CaseStatuses.closed }; + const comp = result.current.actions!.render(caseWithClosedStatus) as React.ReactElement; + const res = appMockRender.render(comp); + + await user.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + expect(res.queryByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.queryByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeFalsy(); + expect(res.queryByTestId('cases-bulk-action-delete')).toBeFalsy(); + expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument(); + expect(res.queryByTestId(`actions-separator-${basicCase.id}`)).toBeFalsy(); + }); + + it('shows actions with combination of reopenCase and other permissions', async () => { + appMockRender = createAppMockRenderer({ + permissions: { + all: false, + read: true, + create: false, + update: false, + delete: true, + reopenCase: true, + push: false, + connectors: true, + settings: false, + createComment: false, + }, + }); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).not.toBe(null); + const caseWithClosedStatus = { ...basicCase, status: CaseStatuses.closed }; + + const comp = result.current.actions!.render(caseWithClosedStatus) as React.ReactElement; + const res = appMockRender.render(comp); + + await user.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + expect(res.queryByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.queryByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeFalsy(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument(); + }); + + it('shows no actions with everything false but read', async () => { + appMockRender = createAppMockRenderer({ + permissions: { + all: false, + read: true, + create: false, + update: false, + delete: false, + reopenCase: false, + push: false, + connectors: true, + settings: false, + createComment: false, + }, + }); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).toBe(null); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx index 4c43201b1eab4..e34f64a2a6283 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx @@ -28,6 +28,7 @@ import { EditTagsFlyout } from '../actions/tags/edit_tags_flyout'; import { useAssigneesAction } from '../actions/assignees/use_assignees_action'; import { EditAssigneesFlyout } from '../actions/assignees/edit_assignees_flyout'; import { useCopyIDAction } from '../actions/copy_id/use_copy_id_action'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean }> = ({ theCase, @@ -38,6 +39,12 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean const closePopover = useCallback(() => setIsPopoverOpen(false), []); const refreshCases = useRefreshCases(); + const shouldDisable = useShouldDisableStatus(); + + const shouldDisableStatus = useMemo(() => { + return shouldDisable([theCase]); + }, [theCase, shouldDisable]); + const deleteAction = useDeleteAction({ isDisabled: false, onAction: closePopover, @@ -83,7 +90,7 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean { id: 0, items: mainPanelItems, title: i18n.ACTIONS }, ]; - if (canUpdate) { + if (!shouldDisableStatus) { mainPanelItems.push({ name: ( { const { permissions } = useCasesContext(); - const shouldShowActions = permissions.update || permissions.delete; + const shouldShowActions = permissions.update || permissions.delete || permissions.reopenCase; return { actions: shouldShowActions diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx index fcf3da36fba96..1838ee3b14f59 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx @@ -17,10 +17,12 @@ import { createAppMockRenderer, noDeleteCasesPermissions, onlyDeleteCasesPermission, + noReopenCasesPermissions, + onlyReopenCasesPermission, } from '../../common/mock'; import { useBulkActions } from './use_bulk_actions'; import * as api from '../../containers/api'; -import { basicCase } from '../../containers/mock'; +import { basicCase, basicCaseClosed } from '../../containers/mock'; jest.mock('../../containers/api'); jest.mock('../../containers/user_profiles/api'); @@ -117,7 +119,7 @@ describe('useBulkActions', () => { "items": Array [ Object { "data-test-subj": "cases-bulk-action-status-open", - "disabled": true, + "disabled": false, "icon": "empty", "key": "cases-bulk-action-status-open", "name": "Open", @@ -523,5 +525,72 @@ describe('useBulkActions', () => { expect(res.queryByTestId('bulk-actions-separator')).toBeFalsy(); }); }); + + it('shows the correct actions with no reopen permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: noReopenCasesPermissions() }); + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCaseClosed] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + await waitForHook(() => { + expect(res.queryByTestId('case-bulk-action-status')).toBeInTheDocument(); + res.queryByTestId('case-bulk-action-status')?.click(); + }); + + await waitForHook(() => { + expect(res.queryByTestId('cases-bulk-action-status-open')).toBeDisabled(); + expect(res.queryByTestId('cases-bulk-action-status-in-progress')).toBeDisabled(); + expect(res.queryByTestId('cases-bulk-action-status-closed')).toBeDisabled(); + }); + }); + + it('shows the correct actions with reopen permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: onlyReopenCasesPermission() }); + const { result } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCaseClosed] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const { modals, flyouts, panels } = result.current; + const renderResult = appMockRender.render( + <> + + {modals} + {flyouts} + + ); + + await waitFor(() => { + expect(renderResult.queryByTestId('case-bulk-action-status')).toBeInTheDocument(); + expect(renderResult.queryByTestId('case-bulk-action-severity')).toBeInTheDocument(); + expect(renderResult.queryByTestId('bulk-actions-separator')).not.toBeInTheDocument(); + expect(renderResult.queryByTestId('case-bulk-action-delete')).not.toBeInTheDocument(); + }); + + userEvent.click(renderResult.getByTestId('case-bulk-action-status')); + + await waitFor(() => { + expect(renderResult.queryByTestId('cases-bulk-action-status-open')).not.toBeDisabled(); + expect( + renderResult.queryByTestId('cases-bulk-action-status-in-progress') + ).not.toBeDisabled(); + expect(renderResult.queryByTestId('cases-bulk-action-status-closed')).not.toBeDisabled(); + }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx index 009dfbf99f262..98828b00369f5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx @@ -76,9 +76,6 @@ export const useBulkActions = ({ const panels = useMemo((): EuiContextMenuPanelDescriptor[] => { const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = []; - const panelsToBuild: EuiContextMenuPanelDescriptor[] = [ - { id: 0, items: mainPanelItems, title: i18n.ACTIONS }, - ]; if (canUpdate) { mainPanelItems.push({ @@ -119,7 +116,13 @@ export const useBulkActions = ({ if (canDelete) { mainPanelItems.push(deleteAction.getAction(selectedCases)); } - + const panelsToBuild: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + items: [...mainPanelItems], // Create a new array instead of using reference + title: i18n.ACTIONS, + }, + ]; if (canUpdate) { panelsToBuild.push({ id: 1, diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx index 6808735a41184..389de5068ed51 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx @@ -94,7 +94,9 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( * Granular permission check for each action is performed * in the useBulkActions hook. */ - const showBulkActions = (permissions.update || permissions.delete) && selectedCases.length > 0; + const showBulkActions = + (permissions.update || permissions.delete || permissions.reopenCase) && + selectedCases.length > 0; const visibleCases = pagination?.pageSize && totalCases > pagination.pageSize ? pagination.pageSize : totalCases; diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index cc6c572275721..eaa334470ab0f 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -39,7 +39,7 @@ const CasesAppComponent: React.FC = ({ getFilesClient, owner: [APP_OWNER], useFetchAlertData: () => [false, {}], - permissions: userCapabilities.generalCases, + permissions: userCapabilities.generalCasesV2, basePath: '/', features: { alerts: { enabled: true, sync: false } }, })} diff --git a/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts b/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts index a26647704785f..4cd015de0c92e 100644 --- a/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts +++ b/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts @@ -21,15 +21,15 @@ jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.MockedFunction; const hasAll = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: allCasesCapabilities(), - generalCases: allCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: allCasesCapabilities(), + generalCasesV2: allCasesCapabilities(), }; const secAllObsReadGenNone = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: readCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: readCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const unrelatedFeatures = { diff --git a/x-pack/plugins/cases/public/components/app/use_available_owners.ts b/x-pack/plugins/cases/public/components/app/use_available_owners.ts index c829b9c590d01..4220ff8cdecd4 100644 --- a/x-pack/plugins/cases/public/components/app/use_available_owners.ts +++ b/x-pack/plugins/cases/public/components/app/use_available_owners.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { APP_ID, FEATURE_ID } from '../../../common/constants'; +import { APP_ID, FEATURE_ID_V2 } from '../../../common/constants'; import { useKibana } from '../../common/lib/kibana'; import type { CasesPermissions } from '../../containers/types'; import { allCasePermissions } from '../../utils/permissions'; @@ -25,7 +25,7 @@ export const useAvailableCasesOwners = ( return Object.entries(kibanaCapabilities).reduce( (availableOwners: string[], [featureId, kibanaCapability]) => { - if (!featureId.endsWith('Cases')) { + if (!featureId.endsWith('CasesV2')) { return availableOwners; } for (const cap of capabilities) { @@ -42,9 +42,9 @@ export const useAvailableCasesOwners = ( }; const getOwnerFromFeatureID = (featureID: string) => { - if (featureID === FEATURE_ID) { + if (featureID === FEATURE_ID_V2) { return APP_ID; } - return featureID.replace('Cases', ''); + return featureID.replace('CasesV2', ''); }; diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index d6c17febb6348..7fd13396086c7 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiButtonEmpty, useEuiTheme } from '@elastic/eui'; import type { CaseStatuses } from '../../../common/types/domain'; @@ -23,6 +23,7 @@ import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_pa import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesFeatures } from '../../common/use_cases_features'; import { useGetCaseConnectors } from '../../containers/use_get_case_connectors'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; export interface CaseActionBarProps { caseData: CaseUI; @@ -67,6 +68,11 @@ const CaseActionBarComponent: React.FC = ({ [caseData.settings, onUpdateField] ); + const shouldDisableStatusFn = useShouldDisableStatus(); + const isStatusMenuDisabled = useMemo(() => { + return shouldDisableStatusFn([caseData]); + }, [caseData, shouldDisableStatusFn]); + return ( = ({ diff --git a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx index 95d36bb058d79..e4497b14ff75e 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx @@ -10,17 +10,24 @@ import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/types/domain'; import { StatusContextMenu } from './status_context_menu'; +import { TestProviders } from '../../common/mock'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; -describe('SyncAlertsSwitch', () => { +jest.mock('../actions/status/use_should_disable_status'); + +describe('StatusContextMenu', () => { const onStatusChanged = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => false); }); it('renders', async () => { const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeTruthy(); @@ -28,11 +35,13 @@ describe('SyncAlertsSwitch', () => { it('renders a simple status badge when disabled', async () => { const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeFalsy(); @@ -41,7 +50,9 @@ describe('SyncAlertsSwitch', () => { it('renders the current status correctly', async () => { const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toBe( @@ -51,7 +62,9 @@ describe('SyncAlertsSwitch', () => { it('changes the status', async () => { const wrapper = mount( - + + + ); wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); @@ -62,14 +75,61 @@ describe('SyncAlertsSwitch', () => { expect(onStatusChanged).toHaveBeenCalledWith('in-progress'); }); - it('does not call onStatusChanged if selection is same as current status', async () => { + it('does not render the button at all if the status cannot change', async () => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); const wrapper = mount( - + + + ); wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); - wrapper.find(`[data-test-subj="case-view-status-dropdown-open"] button`).simulate('click'); + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown-open"] button`)).toHaveLength( + 0 + ); expect(onStatusChanged).not.toHaveBeenCalled(); }); + + it('updates menu items when shouldDisableStatus changes', async () => { + const mockShouldDisableStatus = jest.fn().mockReturnValue(false); + (useShouldDisableStatus as jest.Mock).mockReturnValue(mockShouldDisableStatus); + + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); + + expect(mockShouldDisableStatus).toHaveBeenCalledWith([{ status: CaseStatuses.open }]); + }); + + it('handles all statuses being disabled', async () => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); + + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); + expect(wrapper.find('EuiContextMenuItem').prop('onClick')).toBeUndefined(); + }); + + it('correctly evaluates each status option', async () => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(false); + + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).exists() + ).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx index 422cf1aa44b80..b1c65fc796b46 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx @@ -12,6 +12,7 @@ import type { CaseStatuses } from '../../../common/types/domain'; import { caseStatuses } from '../../../common/types/domain'; import { StatusPopoverButton } from '../status'; import { CHANGE_STATUS } from '../all_cases/translations'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; interface Props { currentStatus: CaseStatuses; @@ -27,6 +28,7 @@ const StatusContextMenuComponent: React.FC = ({ onStatusChanged, }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const shouldDisableStatus = useShouldDisableStatus(); const togglePopover = useCallback( () => setIsPopoverOpen((prevPopoverStatus) => !prevPopoverStatus), [] @@ -57,17 +59,19 @@ const StatusContextMenuComponent: React.FC = ({ const panelItems = useMemo( () => - caseStatuses.map((status: CaseStatuses) => ( - onContextMenuItemClick(status)} - > - - - )), - [currentStatus, onContextMenuItemClick] + caseStatuses + .filter((_: CaseStatuses) => !shouldDisableStatus([{ status: currentStatus }])) + .map((status: CaseStatuses) => ( + onContextMenuItemClick(status)} + > + + + )), + [currentStatus, onContextMenuItemClick, shouldDisableStatus] ); if (disabled) { diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index 85c267f5d05d7..77aee6551ac03 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -98,6 +98,8 @@ export const CasesProvider: FC< read: permissions.read, settings: permissions.settings, update: permissions.update, + reopenCase: permissions.reopenCase, + createComment: permissions.createComment, }, basePath, /** @@ -127,6 +129,8 @@ export const CasesProvider: FC< permissions.read, permissions.settings, permissions.update, + permissions.reopenCase, + permissions.createComment, ] ); diff --git a/x-pack/plugins/cases/public/components/files/add_file.test.tsx b/x-pack/plugins/cases/public/components/files/add_file.test.tsx index 69aa9e87a34e7..9a27b8780db2d 100644 --- a/x-pack/plugins/cases/public/components/files/add_file.test.tsx +++ b/x-pack/plugins/cases/public/components/files/add_file.test.tsx @@ -107,19 +107,9 @@ describe('AddFile', () => { expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument(); }); - it('AddFile is not rendered if user has no create permission', async () => { + it('AddFile is not rendered if user has no createComment permission', async () => { appMockRender = createAppMockRenderer({ - permissions: buildCasesPermissions({ create: false }), - }); - - appMockRender.render(); - - expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument(); - }); - - it('AddFile is not rendered if user has no update permission', async () => { - appMockRender = createAppMockRenderer({ - permissions: buildCasesPermissions({ update: false }), + permissions: buildCasesPermissions({ createComment: false }), }); appMockRender.render(); diff --git a/x-pack/plugins/cases/public/components/files/add_file.tsx b/x-pack/plugins/cases/public/components/files/add_file.tsx index 7b91879834a78..ab83b75920d59 100644 --- a/x-pack/plugins/cases/public/components/files/add_file.tsx +++ b/x-pack/plugins/cases/public/components/files/add_file.tsx @@ -107,7 +107,7 @@ const AddFileComponent: React.FC = ({ caseId }) => { [caseId, createAttachments, owner, refreshAttachmentsTable, showDangerToast, showSuccessToast] ); - return permissions.create && permissions.update ? ( + return permissions.createComment ? ( { it('sets all available solutions correctly', () => { appMockRender = createAppMockRenderer({ owner: [] }); /** - * We set securitySolutionCases capability to not have + * We set securitySolutionCasesV2 capability to not have * any access to cases. This tests that we get the owners * that have at least read access. */ appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, - securitySolutionCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), }; appMockRender.render(); diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index a17dee7423fe3..793405276cdb4 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -16,7 +16,6 @@ import { getManualAlertIdsWithNoRuleId } from './helpers'; import type { UserActionTreeProps } from './types'; import { useUserActionsHandler } from './use_user_actions_handler'; import { NEW_COMMENT_ID } from './constants'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { UserToolTip } from '../user_profiles/user_tooltip'; import { Username } from '../user_profiles/username'; import { HoverableAvatar } from '../user_profiles/hoverable_avatar'; @@ -25,6 +24,7 @@ import { useUserActionsPagination } from './use_user_actions_pagination'; import { useLastPageUserActions } from './use_user_actions_last_page'; import { ShowMoreButton } from './show_more_button'; import { useLastPage } from './use_last_page'; +import { useUserPermissions } from './use_user_permissions'; const getIconsCss = (hasNextPage: boolean | undefined, euiTheme: EuiThemeComputed<{}>): string => { const customSize = hasNextPage @@ -108,10 +108,10 @@ export const UserActions = React.memo((props: UserActionTreeProps) => { const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo); - const { permissions } = useCasesContext(); + const { getCanAddUserComments } = useUserPermissions(); // add-comment markdown is not visible in History filter - const showCommentEditor = permissions.create && userActivityQueryParams.type !== 'action'; + const shouldShowCommentEditor = getCanAddUserComments(userActivityQueryParams); const { commentRefs, @@ -136,7 +136,7 @@ export const UserActions = React.memo((props: UserActionTreeProps) => { [caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs] ); - const bottomActions = showCommentEditor + const bottomActions = shouldShowCommentEditor ? [ { username: ( diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx new file mode 100644 index 0000000000000..e7c712b0df590 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx @@ -0,0 +1,259 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useUserPermissions } from './use_user_permissions'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +jest.mock('../cases_context/use_cases_context'); +const mockUseCasesContext = useCasesContext as jest.Mock; + +describe('useUserPermissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('canUpdate permission', () => { + it('should return true when user has update permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: true, + reopenCase: false, + createComment: false, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canUpdate).toBe(true); + }); + + it('should return false when user lacks update permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: true, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canUpdate).toBe(false); + }); + }); + + describe('canReopenCase permission', () => { + it('should return true when user has reopenCase permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: true, + createComment: false, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canReopenCase).toBe(true); + }); + + it('should return false when user lacks reopenCase permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: true, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canReopenCase).toBe(false); + }); + }); + + describe('getCanAddUserComments permission', () => { + it('should return false when activity type is "action" regardless of createComment permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + const userActivityParams: UserActivityParams = { + page: 1, + perPage: 10, + sortOrder: 'asc', + type: 'action', + }; + + expect(result.current.getCanAddUserComments(userActivityParams)).toBe(false); + }); + + it('should return true when type is not "action" and user has createComment permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + const userActivityParams: UserActivityParams = { + page: 1, + perPage: 10, + sortOrder: 'asc', + type: 'user', + }; + + expect(result.current.getCanAddUserComments(userActivityParams)).toBe(true); + }); + + it('should return false when type is not "action" but user lacks createComment permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: true, + reopenCase: true, + createComment: false, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + const userActivityParams: UserActivityParams = { + page: 1, + perPage: 10, + sortOrder: 'asc', + type: 'user', + }; + + expect(result.current.getCanAddUserComments(userActivityParams)).toBe(false); + }); + }); + + it('should maintain stable references to memoized values when permissions do not change', () => { + const permissions = { + update: true, + reopenCase: true, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }; + + mockUseCasesContext.mockReturnValue({ permissions }); + + const { result, rerender } = renderHook(() => useUserPermissions()); + + const initialCanUpdate = result.current.canUpdate; + const initialCanReopenCase = result.current.canReopenCase; + const initialGetCanAddUserComments = result.current.getCanAddUserComments; + + rerender(); + + expect(result.current.canUpdate).toBe(initialCanUpdate); + expect(result.current.canReopenCase).toBe(initialCanReopenCase); + expect(result.current.getCanAddUserComments).toBe(initialGetCanAddUserComments); + }); + + it('should update memoized values when permissions change', () => { + const initialPermissions = { + update: true, + reopenCase: true, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }; + + mockUseCasesContext.mockReturnValue({ permissions: initialPermissions }); + + const { result, rerender } = renderHook(() => useUserPermissions()); + + const initialCanUpdate = result.current.canUpdate; + const initialCanReopenCase = result.current.canReopenCase; + const initialGetCanAddUserComments = result.current.getCanAddUserComments; + + const newPermissions = { + update: false, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }; + + mockUseCasesContext.mockReturnValue({ permissions: newPermissions }); + rerender(); + + expect(result.current.canUpdate).not.toBe(initialCanUpdate); + expect(result.current.canReopenCase).not.toBe(initialCanReopenCase); + expect(result.current.getCanAddUserComments).toBe(initialGetCanAddUserComments); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx new file mode 100644 index 0000000000000..f0a79a6e285a5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx @@ -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 { useCallback } from 'react'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +export const useUserPermissions = () => { + const { permissions } = useCasesContext(); + + /** + * Determines if a user has the capability to update the case. Reopening a case is not part of this capability. + */ + + const canUpdate = permissions.update; + + /** + * Determines if a user has the capability to change the case from closed => open or closed => in progress + */ + + const canReopenCase = permissions.reopenCase; + + /** + * Determines if a user has the capability to add comments and attachments + */ + const getCanAddUserComments = useCallback( + (userActivityQueryParams: UserActivityParams) => { + if (userActivityQueryParams.type === 'action') return false; + return permissions.createComment; + }, + [permissions.createComment] + ); + + return { getCanAddUserComments, canReopenCase, canUpdate }; +}; diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index 53900a6920f20..92d7abde2f9d2 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -69,7 +69,7 @@ describe('useGetCases', () => { appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, - observabilityCases: { + observabilityCasesV2: { create_cases: true, read_cases: true, update_cases: true, @@ -78,7 +78,7 @@ describe('useGetCases', () => { delete_cases: true, cases_settings: true, }, - securitySolutionCases: { + securitySolutionCasesV2: { create_cases: true, read_cases: true, update_cases: true, diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts index e267c108a9b39..3de6a96979065 100644 --- a/x-pack/plugins/cases/public/mocks.ts +++ b/x-pack/plugins/cases/public/mocks.ts @@ -50,6 +50,8 @@ const helpersMock: jest.Mocked = { push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }), getRuleIdFromEvent: jest.fn(), groupAlertsByRule: jest.fn(), diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap index ebb9501ff8960..b8129f9111b9c 100644 --- a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap +++ b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap @@ -2520,6 +2520,90 @@ Object { } `; +exports[`audit_logger log function event structure creates the correct audit event for operation: "reopenCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_reopen", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to update cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "reopenCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_reopen", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "message": "Failed attempt to update a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "reopenCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_reopen", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User is updating cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "reopenCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_reopen", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "message": "User is updating a case as any owners", +} +`; + exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" with an error and entity 1`] = ` Object { "error": Object { diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap new file mode 100644 index 0000000000000..23575aaad0ddd --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap @@ -0,0 +1,150 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`authorization ensureAuthorized with operation arrays handles multiple operations successfully when authorized 1`] = ` +Array [ + Array [ + Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User is creating cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=1] as owner \\"a\\"", + }, + ], +] +`; + +exports[`authorization ensureAuthorized with operation arrays logs each operation separately 1`] = ` +Array [ + Array [ + Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User is creating cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=1] as owner \\"a\\"", + }, + ], +] +`; + +exports[`authorization ensureAuthorized with operation arrays throws on first unauthorized operation in array 1`] = ` +Array [ + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to create, access case with owners: \\"a\\"", + }, + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to create cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to create, access case with owners: \\"a\\"", + }, + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"a\\"", + }, + ], +] +`; diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.ts b/x-pack/plugins/cases/server/authorization/audit_logger.ts index 338af379bbcc7..2de847586228a 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.ts @@ -82,15 +82,18 @@ export class AuthorizationAuditLogger { operation, }: { owners: string[]; - operation: OperationDetails; + operation: OperationDetails | OperationDetails[]; }) { const ownerMsg = owners.length <= 0 ? 'of any owner' : `with owners: "${owners.join(', ')}"`; + const operations = Array.isArray(operation) ? operation : [operation]; + const operationVerbs = [...new Set(operations.map((op) => op.verbs.present))].join(', '); + const operationDocTypes = [...new Set(operations.map((op) => op.docType))].join(', '); /** * This will take the form: * `Unauthorized to create case with owners: "securitySolution, observability"` * `Unauthorized to access cases of any owner` */ - return `Unauthorized to ${operation.verbs.present} ${operation.docType} ${ownerMsg}`; + return `Unauthorized to ${operationVerbs} ${operationDocTypes} ${ownerMsg}`; } /** diff --git a/x-pack/plugins/cases/server/authorization/authorization.test.ts b/x-pack/plugins/cases/server/authorization/authorization.test.ts index 6385bc03813a0..9ba13ed51dcb3 100644 --- a/x-pack/plugins/cases/server/authorization/authorization.test.ts +++ b/x-pack/plugins/cases/server/authorization/authorization.test.ts @@ -1459,4 +1459,80 @@ describe('authorization', () => { }); }); }); + + describe('ensureAuthorized with operation arrays', () => { + let auth: Authorization; + let securityStart: ReturnType; + let featuresStart: jest.Mocked; + let spacesStart: jest.Mocked; + + beforeEach(async () => { + securityStart = securityMock.createStart(); + securityStart.authz.mode.useRbacForRequest.mockReturnValue(true); + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ hasAllRequested: true })) + ); + + featuresStart = featuresPluginMock.createStart(); + featuresStart.getKibanaFeatures.mockReturnValue([ + { id: '1', cases: ['a'] }, + ] as unknown as KibanaFeature[]); + + spacesStart = createSpacesDisabledFeaturesMock(); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + spaces: spacesStart, + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + }); + + it('handles multiple operations successfully when authorized', async () => { + await expect( + auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [Operations.createCase, Operations.getCase], + }) + ).resolves.not.toThrow(); + + expect(mockLogger.log.mock.calls).toMatchSnapshot(); + }); + + it('throws on first unauthorized operation in array', async () => { + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ hasAllRequested: false })) + ); + + await expect( + auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [Operations.createCase, Operations.getCase], + }) + ).rejects.toThrow('Unauthorized to create, access case with owners: "a"'); + + expect(mockLogger.log.mock.calls).toMatchSnapshot(); + }); + + it('logs each operation separately', async () => { + await auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [Operations.createCase, Operations.getCase], + }); + + expect(mockLogger.log).toHaveBeenCalledTimes(2); + expect(mockLogger.log.mock.calls).toMatchSnapshot(); + }); + + it('handles empty operation array', async () => { + await expect( + auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [], + }) + ).resolves.not.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/cases/server/authorization/authorization.ts b/x-pack/plugins/cases/server/authorization/authorization.ts index ed255a5df18aa..f760e4498d06e 100644 --- a/x-pack/plugins/cases/server/authorization/authorization.ts +++ b/x-pack/plugins/cases/server/authorization/authorization.ts @@ -108,18 +108,17 @@ export class Authorization { operation, }: { entities: OwnerEntity[]; - operation: OperationDetails; + operation: OperationDetails | OperationDetails[]; }) { + const uniqueOwners = Array.from(new Set(entities.map((entity) => entity.owner))); + const operations = Array.isArray(operation) ? operation : [operation]; try { - const uniqueOwners = Array.from(new Set(entities.map((entity) => entity.owner))); - - await this._ensureAuthorized(uniqueOwners, operation); + await this._ensureAuthorized(uniqueOwners, operations); } catch (error) { - this.logSavedObjects({ entities, operation, error }); + this.logSavedObjects({ entities, operation: operations, error }); throw error; } - - this.logSavedObjects({ entities, operation }); + this.logSavedObjects({ entities, operation: operations }); } /** @@ -177,11 +176,15 @@ export class Authorization { error, }: { entities: OwnerEntity[]; - operation: OperationDetails; + operation: OperationDetails | OperationDetails[]; error?: Error; }) { + const operations = Array.isArray(operation) ? operation : [operation]; + for (const entity of entities) { - this.auditLogger.log({ operation, error, entity }); + for (const op of operations) { + this.auditLogger.log({ operation: op, error, entity }); + } } } @@ -197,15 +200,13 @@ export class Authorization { } } - private async _ensureAuthorized(owners: string[], operation: OperationDetails) { + private async _ensureAuthorized(owners: string[], operations: OperationDetails[]) { const { securityAuth } = this; const areAllOwnersAvailable = owners.every((owner) => this.featureCaseOwners.has(owner)); - if (securityAuth && this.shouldCheckAuthorization()) { - const requiredPrivileges: string[] = owners.map((owner) => - securityAuth.actions.cases.get(owner, operation.name) + const requiredPrivileges: string[] = operations.flatMap((operation) => + owners.map((owner) => securityAuth.actions.cases.get(owner, operation.name)) ); - const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request); const { hasAllRequested } = await checkPrivileges({ kibana: requiredPrivileges, @@ -219,14 +220,20 @@ export class Authorization { * as Privileged. * This check will ensure we don't accidentally let these through */ - throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners, operation: operations }) + ); } if (!hasAllRequested) { - throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners, operation: operations }) + ); } } else if (!areAllOwnersAvailable) { - throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners, operation: operations }) + ); } // else security is disabled so let the operation proceed @@ -288,7 +295,6 @@ export class Authorization { const { hasAllRequested, username, privileges } = await checkPrivileges({ kibana: [...requiredPrivileges.keys()], }); - return { hasAllRequested, username, diff --git a/x-pack/plugins/cases/server/authorization/index.ts b/x-pack/plugins/cases/server/authorization/index.ts index 12653aa6079e6..40b6c5d7101c5 100644 --- a/x-pack/plugins/cases/server/authorization/index.ts +++ b/x-pack/plugins/cases/server/authorization/index.ts @@ -59,7 +59,7 @@ const EVENT_TYPES: Record> = { }; /** - * These values need to match the respective values in this file: x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + * These values need to match the respective values in this file: x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts * These are shared between find, get, get all, and delete/delete all * There currently isn't a use case for a user to delete one comment but not all or differentiating between get, get all, * and find operations from a privilege stand point. @@ -182,6 +182,14 @@ const CaseOperations = { docType: 'cases', savedObjectType: CASE_SAVED_OBJECT, }, + [WriteOperations.ReopenCase]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.ReopenCase as const, + action: 'case_reopen', + verbs: updateVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, }; const ConfigurationOperations = { diff --git a/x-pack/plugins/cases/server/authorization/types.ts b/x-pack/plugins/cases/server/authorization/types.ts index f97c6fc597457..1031e2db0ec77 100644 --- a/x-pack/plugins/cases/server/authorization/types.ts +++ b/x-pack/plugins/cases/server/authorization/types.ts @@ -63,6 +63,7 @@ export enum WriteOperations { UpdateComment = 'updateComment', CreateConfiguration = 'createConfiguration', UpdateConfiguration = 'updateConfiguration', + ReopenCase = 'reopenCase', } /** @@ -75,7 +76,7 @@ export interface OperationDetails { ecsType: ArrayElement; /** * The name of the operation to authorize against for the privilege check. - * These values need to match one of the operation strings defined here: x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + * These values need to match one of the operation strings defined here: x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts * * To avoid the authorization strings getting too large, new operations should generally fit within one of the * CasesSupportedOperations. In the situation where a new one is needed we'll have to add it to the security plugin. diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index 0109e6eda8808..755084d624b9f 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomFieldTypes } from '../../../common/types/domain'; +import { CustomFieldTypes, CaseStatuses } from '../../../common/types/domain'; import { MAX_CATEGORY_LENGTH, MAX_DESCRIPTION_LENGTH, @@ -19,6 +19,7 @@ import { } from '../../../common/constants'; import { mockCases } from '../../mocks'; import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; +import { Operations } from '../../authorization'; import { bulkUpdate } from './bulk_update'; describe('update', () => { @@ -1628,5 +1629,135 @@ describe('update', () => { ); }); }); + + describe('Authorization', () => { + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: mockCases }); + clientArgs.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + }); + clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue( + new Map() + ); + }); + + it('checks authorization for updateCase operation', async () => { + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await bulkUpdate( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'Updated title', + }, + ], + }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.authorization.ensureAuthorized).toHaveBeenCalledWith({ + entities: [{ id: mockCases[0].id, owner: mockCases[0].attributes.owner }], + operation: [Operations.updateCase], + }); + }); + + it('checks authorization for both reopenCase and updateCase operations when reopening a case', async () => { + // Mock a closed case + const closedCase = { + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + status: CaseStatuses.closed, + }, + }; + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: [closedCase] }); + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...closedCase }], + }); + + await bulkUpdate( + { + cases: [ + { + id: closedCase.id, + version: closedCase.version ?? '', + status: CaseStatuses.open, + }, + ], + }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.authorization.ensureAuthorized).not.toThrow(); + }); + + it('throws when user is not authorized to update case', async () => { + const error = new Error('Unauthorized'); + clientArgs.authorization.ensureAuthorized.mockRejectedValue(error); + + await expect( + bulkUpdate( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'Updated title', + }, + ], + }, + clientArgs, + casesClientMock + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Unauthorized"` + ); + }); + + it('throws when user is not authorized to reopen case', async () => { + const closedCase = { + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + status: CaseStatuses.closed, + }, + }; + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: [closedCase] }); + + const error = new Error('Unauthorized to reopen case'); + clientArgs.authorization.ensureAuthorized.mockRejectedValueOnce(error); // Reject reopenCase + + await expect( + bulkUpdate( + { + cases: [ + { + id: closedCase.id, + version: closedCase.version ?? '', + status: CaseStatuses.open, + }, + ], + }, + clientArgs, + casesClientMock + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Unauthorized to reopen case"` + ); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.ts index b9984ac53b05e..9a90168b858de 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.ts @@ -272,9 +272,11 @@ function partitionPatchRequest( conflictedCases: CasePatchRequest[]; // This will be a deduped array of case IDs with their corresponding owner casesToAuthorize: OwnerEntity[]; + reopenedCases: CasePatchRequest[]; } { const nonExistingCases: CasePatchRequest[] = []; const conflictedCases: CasePatchRequest[] = []; + const reopenedCases: CasePatchRequest[] = []; const casesToAuthorize: Map = new Map(); for (const reqCase of patchReqCases) { @@ -286,6 +288,13 @@ function partitionPatchRequest( conflictedCases.push(reqCase); // let's try to authorize the conflicted case even though we'll fail after afterwards just in case casesToAuthorize.set(foundCase.id, { id: foundCase.id, owner: foundCase.attributes.owner }); + } else if ( + reqCase.status != null && + foundCase.attributes.status !== reqCase.status && + foundCase.attributes.status === CaseStatuses.closed + ) { + // Track cases that are closed and a user is attempting to reopen + reopenedCases.push(reqCase); } else { casesToAuthorize.set(foundCase.id, { id: foundCase.id, owner: foundCase.attributes.owner }); } @@ -294,6 +303,7 @@ function partitionPatchRequest( return { nonExistingCases, conflictedCases, + reopenedCases, casesToAuthorize: Array.from(casesToAuthorize.values()), }; } @@ -344,14 +354,17 @@ export const bulkUpdate = async ( return acc; }, new Map()); - const { nonExistingCases, conflictedCases, casesToAuthorize } = partitionPatchRequest( - casesMap, - query.cases - ); + const { nonExistingCases, conflictedCases, casesToAuthorize, reopenedCases } = + partitionPatchRequest(casesMap, query.cases); + + const operationsToAuthorize = + reopenedCases.length > 0 + ? [Operations.reopenCase, Operations.updateCase] + : [Operations.updateCase]; await authorization.ensureAuthorized({ entities: casesToAuthorize, - operation: Operations.updateCase, + operation: operationsToAuthorize, }); if (nonExistingCases.length > 0) { diff --git a/x-pack/plugins/cases/server/connectors/cases/index.test.ts b/x-pack/plugins/cases/server/connectors/cases/index.test.ts index 5c7b29ef4e704..7b6d244d165b3 100644 --- a/x-pack/plugins/cases/server/connectors/cases/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/index.test.ts @@ -36,6 +36,7 @@ describe('getCasesConnectorType', () => { 'cases:my-owner/updateComment', 'cases:my-owner/deleteComment', 'cases:my-owner/findConfigurations', + 'cases:my-owner/reopenCase', ]); }); @@ -356,6 +357,7 @@ describe('getCasesConnectorType', () => { 'cases:securitySolution/updateComment', 'cases:securitySolution/deleteComment', 'cases:securitySolution/findConfigurations', + 'cases:securitySolution/reopenCase', ]); }); @@ -376,6 +378,7 @@ describe('getCasesConnectorType', () => { 'cases:observability/updateComment', 'cases:observability/deleteComment', 'cases:observability/findConfigurations', + 'cases:observability/reopenCase', ]); }); @@ -396,6 +399,7 @@ describe('getCasesConnectorType', () => { 'cases:securitySolution/updateComment', 'cases:securitySolution/deleteComment', 'cases:securitySolution/findConfigurations', + 'cases:securitySolution/reopenCase', ]); }); }); diff --git a/x-pack/plugins/cases/server/connectors/cases/utils.test.ts b/x-pack/plugins/cases/server/connectors/cases/utils.test.ts index 976a7eadb5aec..55ffb5c7170bd 100644 --- a/x-pack/plugins/cases/server/connectors/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/utils.test.ts @@ -507,6 +507,7 @@ describe('utils', () => { 'cases:my-owner/updateComment', 'cases:my-owner/deleteComment', 'cases:my-owner/findConfigurations', + 'cases:my-owner/reopenCase', ]); }); }); diff --git a/x-pack/plugins/cases/server/connectors/cases/utils.ts b/x-pack/plugins/cases/server/connectors/cases/utils.ts index a2513027c9cb3..b9cd2982553e3 100644 --- a/x-pack/plugins/cases/server/connectors/cases/utils.ts +++ b/x-pack/plugins/cases/server/connectors/cases/utils.ts @@ -109,7 +109,7 @@ export const buildCustomFieldsForRequest = ( export const constructRequiredKibanaPrivileges = (owner: string): string[] => { /** * Kibana features privileges are defined in - * x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + * x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts */ return [ `cases:${owner}/createCase`, @@ -120,5 +120,6 @@ export const constructRequiredKibanaPrivileges = (owner: string): string[] => { `cases:${owner}/updateComment`, `cases:${owner}/deleteComment`, `cases:${owner}/findConfigurations`, + `cases:${owner}/reopenCase`, ]; }; diff --git a/x-pack/plugins/cases/server/features/constants.ts b/x-pack/plugins/cases/server/features/constants.ts new file mode 100644 index 0000000000000..fb0a0f4554dee --- /dev/null +++ b/x-pack/plugins/cases/server/features/constants.ts @@ -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. + */ + +/** + * Unique sub privilege ids for cases. + * @description When upgrading (creating new versions), the sub-privileges + * do not need to be versioned as they are appended to the top level privilege id which is the only id + * that will need to be versioned + */ + +export const CASES_DELETE_SUB_PRIVILEGE_ID = 'cases_delete'; +export const CASES_SETTINGS_SUB_PRIVILEGE_ID = 'cases_settings'; +export const CASES_CREATE_COMMENT_SUB_PRIVILEGE_ID = 'create_comment'; +export const CASES_REOPEN_SUB_PRIVILEGE_ID = 'case_reopen'; diff --git a/x-pack/plugins/cases/server/features/index.ts b/x-pack/plugins/cases/server/features/index.ts new file mode 100644 index 0000000000000..afa3dfab9b311 --- /dev/null +++ b/x-pack/plugins/cases/server/features/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { getV1 } from './v1'; +import { getV2 } from './v2'; + +export const getCasesKibanaFeatures = (): { + v1: KibanaFeatureConfig; + v2: KibanaFeatureConfig; +} => ({ v1: getV1(), v2: getV2() }); diff --git a/x-pack/plugins/cases/server/features.ts b/x-pack/plugins/cases/server/features/v1.ts similarity index 67% rename from x-pack/plugins/cases/server/features.ts rename to x-pack/plugins/cases/server/features/v1.ts index f8f162b2ae3dc..25a43434f3723 100644 --- a/x-pack/plugins/cases/server/features.ts +++ b/x-pack/plugins/cases/server/features/v1.ts @@ -12,8 +12,9 @@ import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/s import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; -import { APP_ID, FEATURE_ID } from '../common/constants'; -import { createUICapabilities, getApiTags } from '../common'; +import { APP_ID, FEATURE_ID, FEATURE_ID_V2 } from '../../common/constants'; +import { createUICapabilities, getApiTags } from '../../common'; +import { CASES_DELETE_SUB_PRIVILEGE_ID, CASES_SETTINGS_SUB_PRIVILEGE_ID } from './constants'; /** * The order of appearance in the feature privilege page @@ -23,14 +24,24 @@ import { createUICapabilities, getApiTags } from '../common'; const FEATURE_ORDER = 3100; -export const getCasesKibanaFeature = (): KibanaFeatureConfig => { +export const getV1 = (): KibanaFeatureConfig => { const capabilities = createUICapabilities(); const apiTags = getApiTags(APP_ID); return { + deprecated: { + notice: i18n.translate('xpack.cases.features.casesFeature.deprecationMessage', { + defaultMessage: + 'The {currentId} permissions are deprecated, please see {casesFeatureIdV2}.', + values: { + currentId: FEATURE_ID, + casesFeatureIdV2: FEATURE_ID_V2, + }, + }), + }, id: FEATURE_ID, - name: i18n.translate('xpack.cases.features.casesFeatureName', { - defaultMessage: 'Cases', + name: i18n.translate('xpack.cases.features.casesFeatureNameDeprecated', { + defaultMessage: 'Cases (Deprecated)', }), category: DEFAULT_APP_CATEGORIES.management, scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], @@ -42,12 +53,14 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { cases: [APP_ID], privileges: { all: { - api: apiTags.all, + api: [...apiTags.all, ...apiTags.createComment], cases: { create: [APP_ID], read: [APP_ID], update: [APP_ID], push: [APP_ID], + createComment: [APP_ID], + reopenCase: [APP_ID], }, management: { insightsAndAlerting: [APP_ID], @@ -57,6 +70,15 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { read: [...filesSavedObjectTypes], }, ui: capabilities.all, + replacedBy: { + default: [{ feature: FEATURE_ID_V2, privileges: ['all'] }], + minimal: [ + { + feature: FEATURE_ID_V2, + privileges: ['minimal_all', 'create_comment', 'case_reopen'], + }, + ], + }, }, read: { api: apiTags.read, @@ -71,6 +93,10 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { read: [...filesSavedObjectTypes], }, ui: capabilities.read, + replacedBy: { + default: [{ feature: FEATURE_ID_V2, privileges: ['read'] }], + minimal: [{ feature: FEATURE_ID_V2, privileges: ['minimal_read'] }], + }, }, }, subFeatures: [ @@ -84,7 +110,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { privileges: [ { api: apiTags.delete, - id: 'cases_delete', + id: CASES_DELETE_SUB_PRIVILEGE_ID, name: i18n.translate('xpack.cases.features.deleteSubFeatureDetails', { defaultMessage: 'Delete cases and comments', }), @@ -97,6 +123,9 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { delete: [APP_ID], }, ui: capabilities.delete, + replacedBy: [ + { feature: FEATURE_ID_V2, privileges: [CASES_DELETE_SUB_PRIVILEGE_ID] }, + ], }, ], }, @@ -111,7 +140,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { groupType: 'independent', privileges: [ { - id: 'cases_settings', + id: CASES_SETTINGS_SUB_PRIVILEGE_ID, name: i18n.translate('xpack.cases.features.casesSettingsSubFeatureDetails', { defaultMessage: 'Edit case settings', }), @@ -124,6 +153,9 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { settings: [APP_ID], }, ui: capabilities.settings, + replacedBy: [ + { feature: FEATURE_ID_V2, privileges: [CASES_SETTINGS_SUB_PRIVILEGE_ID] }, + ], }, ], }, diff --git a/x-pack/plugins/cases/server/features/v2.ts b/x-pack/plugins/cases/server/features/v2.ts new file mode 100644 index 0000000000000..fca97303f02ab --- /dev/null +++ b/x-pack/plugins/cases/server/features/v2.ts @@ -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 { i18n } from '@kbn/i18n'; + +import type { KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; + +import { KibanaFeatureScope } from '@kbn/features-plugin/common'; +import { APP_ID, FEATURE_ID_V2 } from '../../common/constants'; +import { createUICapabilities, getApiTags } from '../../common'; +import { + CASES_DELETE_SUB_PRIVILEGE_ID, + CASES_SETTINGS_SUB_PRIVILEGE_ID, + CASES_CREATE_COMMENT_SUB_PRIVILEGE_ID, + CASES_REOPEN_SUB_PRIVILEGE_ID, +} from './constants'; + +/** + * The order of appearance in the feature privilege page + * under the management section. Cases should be under + * the Actions and Connectors feature + */ + +const FEATURE_ORDER = 3100; + +export const getV2 = (): KibanaFeatureConfig => { + const capabilities = createUICapabilities(); + const apiTags = getApiTags(APP_ID); + + return { + id: FEATURE_ID_V2, + name: i18n.translate('xpack.cases.features.casesFeatureName', { + defaultMessage: 'Cases', + }), + category: DEFAULT_APP_CATEGORIES.management, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [], + order: FEATURE_ORDER, + management: { + insightsAndAlerting: [APP_ID], + }, + cases: [APP_ID], + privileges: { + all: { + api: apiTags.all, + cases: { + create: [APP_ID], + read: [APP_ID], + update: [APP_ID], + push: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + ui: capabilities.all, + }, + read: { + api: apiTags.read, + cases: { + read: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [], + read: [...filesSavedObjectTypes], + }, + ui: capabilities.read, + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.cases.features.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.delete, + id: CASES_DELETE_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.deleteSubFeatureDetails', { + defaultMessage: 'Delete cases and comments', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + delete: [APP_ID], + }, + ui: capabilities.delete, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.cases.features.casesSettingsSubFeatureName', { + defaultMessage: 'Case settings', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: CASES_SETTINGS_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.casesSettingsSubFeatureDetails', { + defaultMessage: 'Edit case settings', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + settings: [APP_ID], + }, + ui: capabilities.settings, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.cases.features.addCommentsSubFeatureName', { + defaultMessage: 'Create comments & attachments', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.createComment, + id: CASES_CREATE_COMMENT_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.addCommentsSubFeatureDetails', { + defaultMessage: 'Add comments to cases', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + createComment: [APP_ID], + }, + ui: capabilities.createComment, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.cases.features.reopenCaseSubFeatureName', { + defaultMessage: 'Re-open', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: CASES_REOPEN_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.reopenCaseSubFeatureDetails', { + defaultMessage: 'Re-open closed cases', + }), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + cases: { + reopenCase: [APP_ID], + }, + ui: capabilities.reopenCase, + }, + ], + }, + ], + }, + ], + }; +}; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index b40089ff75050..dfd4c013f0d58 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -30,7 +30,7 @@ import type { CasesServerStartDependencies, } from './types'; import { CasesClientFactory } from './client/factory'; -import { getCasesKibanaFeature } from './features'; +import { getCasesKibanaFeatures } from './features'; import { registerRoutes } from './routes/api/register_routes'; import { getExternalRoutes } from './routes/api/get_external_routes'; import { createCasesTelemetry, scheduleCasesTelemetryTask } from './telemetry'; @@ -92,7 +92,11 @@ export class CasePlugin this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory; if (this.caseConfig.stack.enabled) { - plugins.features.registerKibanaFeature(getCasesKibanaFeature()); + // V1 is deprecated, but has to be maintained for the time being + // https://github.com/elastic/kibana/pull/186800#issue-2369812818 + const casesFeatures = getCasesKibanaFeatures(); + plugins.features.registerKibanaFeature(casesFeatures.v1); + plugins.features.registerKibanaFeature(casesFeatures.v2); } registerSavedObjects({ diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 188fade8dd2cb..1939d0b5e4e49 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -188,6 +188,7 @@ export interface FeatureKibanaPrivileges { read?: readonly string[]; /** * List of case owners which users should have update access to when granted this privilege. + * This privilege does NOT provide access to re-opening a case. Please see `reopenCase` for said functionality. * @example * ```ts * { @@ -216,6 +217,26 @@ export interface FeatureKibanaPrivileges { * ``` */ settings?: readonly string[]; + /** + * List of case owners whose users should have createComment access when granted this privilege. + * @example + * ```ts + * { + * createComment: ['securitySolution'] + * } + * ``` + */ + createComment?: readonly string[]; + /** + * List of case owners whose users should have reopenCase access when granted this privilege. + * @example + * ```ts + * { + * reopenCase: ['securitySolution'] + * } + * ``` + */ + reopenCase?: readonly string[]; }; /** diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index c91244e2f1d9d..b8df9e9c2117b 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -557,9 +557,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -716,9 +718,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1050,9 +1054,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1190,9 +1196,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1349,9 +1357,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1683,9 +1693,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, diff --git a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts index 58a39c85bf9e9..c7d501bb17cf8 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -78,6 +78,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -148,6 +150,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -217,6 +221,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -288,6 +294,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -329,6 +337,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -391,6 +401,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-sub-type'], }, @@ -438,6 +450,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -506,6 +520,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -568,6 +584,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-sub-type'], }, @@ -615,6 +633,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -683,6 +703,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -746,6 +768,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -796,6 +820,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type', 'cases-delete-sub-type'], push: ['cases-push-type', 'cases-push-sub-type'], settings: ['cases-settings-type', 'cases-settings-sub-type'], + createComment: ['cases-create-comment-type', 'cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-type', 'cases-reopen-sub-type'], }, ui: ['ui-action', 'ui-sub-type'], }, @@ -832,6 +858,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-action', 'ui-sub-type'], }, @@ -875,6 +903,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -980,6 +1010,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1015,6 +1047,8 @@ describe('featurePrivilegeIterator', () => { delete: [], push: [], settings: [], + createComment: [], + reopenCase: [], }, ui: ['ui-action'], }, @@ -1056,6 +1090,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1119,6 +1155,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1169,6 +1207,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type', 'cases-delete-sub-type'], push: ['cases-push-type', 'cases-push-sub-type'], settings: ['cases-settings-type', 'cases-settings-sub-type'], + createComment: ['cases-create-comment-type', 'cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-type', 'cases-reopen-sub-type'], }, ui: ['ui-action', 'ui-sub-type'], }, @@ -1362,6 +1402,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1412,6 +1454,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1448,6 +1492,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1489,6 +1535,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1580,6 +1628,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1615,6 +1665,8 @@ describe('featurePrivilegeIterator', () => { delete: [], push: [], settings: [], + createComment: [], + reopenCase: [], }, ui: ['ui-action'], }, diff --git a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts index 0d1dc8e3ab788..a9d7336ea0a22 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts +++ b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts @@ -151,6 +151,14 @@ function mergeWithSubFeatures( mergedConfig.cases?.settings ?? [], subFeaturePrivilege.cases?.settings ?? [] ), + createComment: mergeArrays( + mergedConfig.cases?.createComment ?? [], + subFeaturePrivilege.cases?.createComment ?? [] + ), + reopenCase: mergeArrays( + mergedConfig.cases?.reopenCase ?? [], + subFeaturePrivilege.cases?.reopenCase ?? [] + ), }; } return mergedConfig; diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 581fdc1037e2a..ce444c41e477d 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -83,6 +83,8 @@ const casesSchemaObject = schema.maybe( delete: schema.maybe(casesSchema), push: schema.maybe(casesSchema), settings: schema.maybe(casesSchema), + createComment: schema.maybe(casesSchema), + reopenCase: schema.maybe(casesSchema), }) ); diff --git a/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx index 25ffef0456e42..9154a2c77bf4a 100644 --- a/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx @@ -24,7 +24,7 @@ import { ALERT_STATUS, } from '@kbn/rule-data-utils'; import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; -import { APP_ID as CASE_APP_ID, FEATURE_ID as CASE_GENERAL_ID } from '@kbn/cases-plugin/common'; +import { APP_ID as CASE_APP_ID, FEATURE_ID_V2 as CASE_GENERAL_ID } from '@kbn/cases-plugin/common'; import { MANAGEMENT_APP_ID } from '@kbn/deeplinks-management/constants'; import { getAlertFlyout } from './use_alerts_flyout'; import { diff --git a/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx index 1d42716bf405d..011fb93553ac4 100644 --- a/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx +++ b/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -120,6 +120,8 @@ describe('AddToCaseAction', function () { push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }, }) ); diff --git a/x-pack/plugins/observability_solution/observability/common/index.ts b/x-pack/plugins/observability_solution/observability/common/index.ts index 4baaf7957fa81..f43090d799fdf 100644 --- a/x-pack/plugins/observability_solution/observability/common/index.ts +++ b/x-pack/plugins/observability_solution/observability/common/index.ts @@ -61,7 +61,9 @@ export { getProbabilityFromProgressiveLoadingQuality, } from './progressive_loading'; +/** @deprecated deprecated in 8.17. Please use casesFeatureIdV2 instead */ export const casesFeatureId = 'observabilityCases'; +export const casesFeatureIdV2 = 'observabilityCasesV2'; export const sloFeatureId = 'slo'; // The ID of the observability app. Should more appropriately be called // 'observability' but it's used in telemetry by applicationUsage so we don't diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx index 071b75ab89632..cf0c4aa3c8b60 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx @@ -159,7 +159,7 @@ export function AlertActions({ ); const actionsMenuItems = [ - ...(userCasesPermissions.create && userCasesPermissions.read + ...(userCasesPermissions.createComment && userCasesPermissions.read ? [ ({ + deprecated: { + // TODO: Add docLinks to link to documentation about the deprecation + notice: i18n.translate( + 'xpack.observability.featureRegistry.linkObservabilityTitle.deprecationMessage', + { + defaultMessage: + 'The {currentId} permissions are deprecated, please see {casesFeatureIdV2}.', + values: { + currentId: casesFeatureId, + casesFeatureIdV2, + }, + } + ), + }, + id: casesFeatureId, + name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitleDeprecated', { + defaultMessage: 'Cases (Deprecated)', + }), + order: 1100, + category: DEFAULT_APP_CATEGORIES.observability, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: [observabilityFeatureId], + privileges: { + all: { + api: [...casesApiTags.all, ...casesApiTags.createComment], + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + create: [observabilityFeatureId], + read: [observabilityFeatureId], + update: [observabilityFeatureId], + push: [observabilityFeatureId], + createComment: [observabilityFeatureId], + reopenCase: [observabilityFeatureId], + }, + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.all, + replacedBy: { + default: [{ feature: casesFeatureIdV2, privileges: ['all'] }], + minimal: [ + { + feature: casesFeatureIdV2, + privileges: ['minimal_all', 'create_comment', 'case_reopen'], + }, + ], + }, + }, + read: { + api: casesApiTags.read, + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + read: [observabilityFeatureId], + }, + savedObject: { + all: [], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.read, + replacedBy: { + default: [{ feature: casesFeatureIdV2, privileges: ['read'] }], + minimal: [{ feature: casesFeatureIdV2, privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: casesApiTags.delete, + id: 'cases_delete', + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureDetails', { + defaultMessage: 'Delete cases and comments', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + delete: [observabilityFeatureId], + }, + ui: casesCapabilities.delete, + replacedBy: [{ feature: casesFeatureIdV2, privileges: ['cases_delete'] }], + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.casesSettingsSubFeatureName', { + defaultMessage: 'Case settings', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cases_settings', + name: i18n.translate( + 'xpack.observability.featureRegistry.casesSettingsSubFeatureDetails', + { + defaultMessage: 'Edit case settings', + } + ), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + settings: [observabilityFeatureId], + }, + ui: casesCapabilities.settings, + replacedBy: [{ feature: casesFeatureIdV2, privileges: ['cases_settings'] }], + }, + ], + }, + ], + }, + ], +}); diff --git a/x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts b/x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts new file mode 100644 index 0000000000000..52b501a62bb2e --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts @@ -0,0 +1,181 @@ +/* + * 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 { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; +import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; +import { i18n } from '@kbn/i18n'; +import { KibanaFeatureConfig, KibanaFeatureScope } from '@kbn/features-plugin/common'; +import { CasesUiCapabilities, CasesApiTags } from '@kbn/cases-plugin/common'; +import { casesFeatureIdV2, casesFeatureId, observabilityFeatureId } from '../../common'; + +export const getCasesFeatureV2 = ( + casesCapabilities: CasesUiCapabilities, + casesApiTags: CasesApiTags +): KibanaFeatureConfig => ({ + id: casesFeatureIdV2, + name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', { + defaultMessage: 'Cases', + }), + order: 1100, + category: DEFAULT_APP_CATEGORIES.observability, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: [observabilityFeatureId], + privileges: { + all: { + api: casesApiTags.all, + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + create: [observabilityFeatureId], + read: [observabilityFeatureId], + update: [observabilityFeatureId], + push: [observabilityFeatureId], + }, + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.all, + }, + read: { + api: casesApiTags.read, + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + read: [observabilityFeatureId], + }, + savedObject: { + all: [], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.read, + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: casesApiTags.delete, + id: 'cases_delete', + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureDetails', { + defaultMessage: 'Delete cases and comments', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + delete: [observabilityFeatureId], + }, + ui: casesCapabilities.delete, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.casesSettingsSubFeatureName', { + defaultMessage: 'Case settings', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cases_settings', + name: i18n.translate( + 'xpack.observability.featureRegistry.casesSettingsSubFeatureDetails', + { + defaultMessage: 'Edit case settings', + } + ), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + settings: [observabilityFeatureId], + }, + ui: casesCapabilities.settings, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.addCommentsSubFeatureName', { + defaultMessage: 'Create comments & attachments', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: casesApiTags.createComment, + id: 'create_comment', + name: i18n.translate( + 'xpack.observability.featureRegistry.addCommentsSubFeatureDetails', + { + defaultMessage: 'Add comments to cases', + } + ), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + createComment: [observabilityFeatureId], + }, + ui: casesCapabilities.createComment, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.reopenCaseSubFeatureName', { + defaultMessage: 'Re-open', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'case_reopen', + name: i18n.translate( + 'xpack.observability.featureRegistry.reopenCaseSubFeatureDetails', + { + defaultMessage: 'Re-open closed cases', + } + ), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + cases: { + reopenCase: [observabilityFeatureId], + }, + ui: casesCapabilities.reopenCase, + }, + ], + }, + ], + }, + ], +}); diff --git a/x-pack/plugins/observability_solution/observability/server/plugin.ts b/x-pack/plugins/observability_solution/observability/server/plugin.ts index 7f9a37a5a26c4..b98fe316c712e 100644 --- a/x-pack/plugins/observability_solution/observability/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability/server/plugin.ts @@ -21,7 +21,6 @@ import { } from '@kbn/core/server'; import { LogsExplorerLocatorParams, LOGS_EXPLORER_LOCATOR_ID } from '@kbn/deeplinks-observability'; import { FeaturesPluginSetup } from '@kbn/features-plugin/server'; -import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import { i18n } from '@kbn/i18n'; import { @@ -41,7 +40,7 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; import { ObservabilityConfig } from '.'; -import { casesFeatureId, observabilityFeatureId } from '../common'; +import { observabilityFeatureId } from '../common'; import { kubernetesGuideConfig, kubernetesGuideId, @@ -58,6 +57,8 @@ import { registerRoutes } from './routes/register_routes'; import { threshold } from './saved_objects/threshold'; import { AlertDetailsContextualInsightsService } from './services'; import { uiSettings } from './ui_settings'; +import { getCasesFeature } from './features/cases_v1'; +import { getCasesFeatureV2 } from './features/cases_v2'; export type ObservabilityPluginSetup = ReturnType; @@ -110,112 +111,8 @@ export class ObservabilityPlugin implements Plugin { const alertDetailsContextualInsightsService = new AlertDetailsContextualInsightsService(); - plugins.features.registerKibanaFeature({ - id: casesFeatureId, - name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', { - defaultMessage: 'Cases', - }), - order: 1100, - category: DEFAULT_APP_CATEGORIES.observability, - scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], - app: [casesFeatureId, 'kibana'], - catalogue: [observabilityFeatureId], - cases: [observabilityFeatureId], - privileges: { - all: { - api: casesApiTags.all, - app: [casesFeatureId, 'kibana'], - catalogue: [observabilityFeatureId], - cases: { - create: [observabilityFeatureId], - read: [observabilityFeatureId], - update: [observabilityFeatureId], - push: [observabilityFeatureId], - }, - savedObject: { - all: [...filesSavedObjectTypes], - read: [...filesSavedObjectTypes], - }, - ui: casesCapabilities.all, - }, - read: { - api: casesApiTags.read, - app: [casesFeatureId, 'kibana'], - catalogue: [observabilityFeatureId], - cases: { - read: [observabilityFeatureId], - }, - savedObject: { - all: [], - read: [...filesSavedObjectTypes], - }, - ui: casesCapabilities.read, - }, - }, - subFeatures: [ - { - name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', { - defaultMessage: 'Delete', - }), - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - api: casesApiTags.delete, - id: 'cases_delete', - name: i18n.translate( - 'xpack.observability.featureRegistry.deleteSubFeatureDetails', - { - defaultMessage: 'Delete cases and comments', - } - ), - includeIn: 'all', - savedObject: { - all: [...filesSavedObjectTypes], - read: [...filesSavedObjectTypes], - }, - cases: { - delete: [observabilityFeatureId], - }, - ui: casesCapabilities.delete, - }, - ], - }, - ], - }, - { - name: i18n.translate('xpack.observability.featureRegistry.casesSettingsSubFeatureName', { - defaultMessage: 'Case settings', - }), - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - id: 'cases_settings', - name: i18n.translate( - 'xpack.observability.featureRegistry.casesSettingsSubFeatureDetails', - { - defaultMessage: 'Edit case settings', - } - ), - includeIn: 'all', - savedObject: { - all: [...filesSavedObjectTypes], - read: [...filesSavedObjectTypes], - }, - cases: { - settings: [observabilityFeatureId], - }, - ui: casesCapabilities.settings, - }, - ], - }, - ], - }, - ], - }); + plugins.features.registerKibanaFeature(getCasesFeature(casesCapabilities, casesApiTags)); + plugins.features.registerKibanaFeature(getCasesFeatureV2(casesCapabilities, casesApiTags)); let annotationsApiPromise: Promise | undefined; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/index.ts index b4b7731d166b7..f483bcc5dc269 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/index.ts @@ -8,7 +8,7 @@ import { AlertConsumers } from '@kbn/rule-data-utils'; export const observabilityFeatureId = 'observability'; export const observabilityAppId = 'observability-overview'; -export const casesFeatureId = 'observabilityCases'; +export const casesFeatureId = 'observabilityCasesV2'; export const sloFeatureId = 'slo'; // SLO alerts table in slo detail page diff --git a/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts b/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts index 0ceea46ad0d38..0b3699e49b40c 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts @@ -14,6 +14,8 @@ export const noCasesPermissions = () => ({ push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }); export const allCasesPermissions = () => ({ @@ -25,4 +27,6 @@ export const allCasesPermissions = () => ({ push: true, connectors: true, settings: true, + createComment: true, + reopenCase: true, }); diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts index 6e3f6751d11dc..49cb34ccdc09e 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts @@ -94,7 +94,7 @@ const roles = [ applications: [ { application: 'kibana-.kibana', - privileges: ['feature_securitySolutionCases.a;;'], + privileges: ['feature_securitySolutionCasesV2.a;;'], resources: ['*'], }, ], @@ -184,7 +184,7 @@ const roles = [ applications: [ { application: 'kibana-.kibana', - privileges: ['feature_securitySolutionCases.a;;'], + privileges: ['feature_securitySolutionCasesV2.a;;'], resources: ['space:default'], }, ], diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 137afe7ba9112..b366a0e555357 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -21,7 +21,7 @@ export const APP_ID = 'securitySolution' as const; export const APP_UI_ID = 'securitySolutionUI' as const; export const ASSISTANT_FEATURE_ID = 'securitySolutionAssistant' as const; export const ATTACK_DISCOVERY_FEATURE_ID = 'securitySolutionAttackDiscovery' as const; -export const CASES_FEATURE_ID = 'securitySolutionCases' as const; +export const CASES_FEATURE_ID = 'securitySolutionCasesV2' as const; export const SERVER_APP_ID = 'siem' as const; export const APP_NAME = 'Security' as const; export const APP_ICON = 'securityAnalyticsApp' as const; diff --git a/x-pack/plugins/security_solution/common/test/ess_roles.json b/x-pack/plugins/security_solution/common/test/ess_roles.json index 94bd3d57a6d7b..361d5d4321756 100644 --- a/x-pack/plugins/security_solution/common/test/ess_roles.json +++ b/x-pack/plugins/security_solution/common/test/ess_roles.json @@ -30,7 +30,7 @@ "siem": ["read", "read_alerts"], "securitySolutionAssistant": ["none"], "securitySolutionAttackDiscovery": ["none"], - "securitySolutionCases": ["read"], + "securitySolutionCasesV2": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, @@ -79,7 +79,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["read"], "builtInAlerts": ["all"] }, @@ -128,7 +128,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "builtInAlerts": ["all"] }, "spaces": ["*"], diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx index 9701114915507..af2150b4010d9 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx @@ -33,8 +33,8 @@ const TakeActionComponent: React.FC = ({ attackDiscovery, replacements }) const { cases } = useKibana().services; const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const canUserCreateAndReadCases = useCallback( - () => userCasesPermissions.create && userCasesPermissions.read, - [userCasesPermissions.create, userCasesPermissions.read] + () => userCasesPermissions.createComment && userCasesPermissions.read, + [userCasesPermissions.createComment, userCasesPermissions.read] ); const { disabled: addToCaseDisabled, onAddToNewCase } = useAddToNewCase({ canUserCreateAndReadCases, diff --git a/x-pack/plugins/security_solution/public/cases_test_utils.ts b/x-pack/plugins/security_solution/public/cases_test_utils.ts index dc70dcab33eaa..f3c356507bcfe 100644 --- a/x-pack/plugins/security_solution/public/cases_test_utils.ts +++ b/x-pack/plugins/security_solution/public/cases_test_utils.ts @@ -15,6 +15,8 @@ export const noCasesCapabilities = (): CasesCapabilities => ({ push_cases: false, cases_connectors: false, cases_settings: false, + case_reopen: false, + create_comment: false, }); export const readCasesCapabilities = (): CasesCapabilities => ({ @@ -25,6 +27,8 @@ export const readCasesCapabilities = (): CasesCapabilities => ({ push_cases: false, cases_connectors: true, cases_settings: false, + case_reopen: false, + create_comment: false, }); export const allCasesCapabilities = (): CasesCapabilities => ({ @@ -35,6 +39,8 @@ export const allCasesCapabilities = (): CasesCapabilities => ({ push_cases: true, cases_connectors: true, cases_settings: true, + case_reopen: true, + create_comment: true, }); export const noCasesPermissions = (): CasesPermissions => ({ @@ -46,6 +52,8 @@ export const noCasesPermissions = (): CasesPermissions => ({ push: false, connectors: false, settings: false, + reopenCase: false, + createComment: false, }); export const readCasesPermissions = (): CasesPermissions => ({ @@ -57,6 +65,8 @@ export const readCasesPermissions = (): CasesPermissions => ({ push: false, connectors: true, settings: false, + reopenCase: false, + createComment: false, }); export const writeCasesPermissions = (): CasesPermissions => ({ @@ -68,6 +78,8 @@ export const writeCasesPermissions = (): CasesPermissions => ({ push: true, connectors: true, settings: true, + reopenCase: true, + createComment: true, }); export const allCasesPermissions = (): CasesPermissions => ({ @@ -79,4 +91,6 @@ export const allCasesPermissions = (): CasesPermissions => ({ push: true, connectors: true, settings: true, + reopenCase: true, + createComment: true, }); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx index aa11ced2603a9..c07bbd651316a 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx @@ -59,7 +59,7 @@ export const useAddToExistingCase = ({ disabled: lensAttributes == null || timeRange == null || - !userCasesPermissions.create || + !userCasesPermissions.createComment || !userCasesPermissions.read, }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx index c2ac628000fa7..7803e27b2453f 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx @@ -60,7 +60,7 @@ export const useAddToNewCase = ({ disabled: lensAttributes == null || timeRange == null || - !userCasesPermissions.create || + !userCasesPermissions.createComment || !userCasesPermissions.read, }; }; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.tsx b/x-pack/plugins/security_solution/public/common/links/links.test.tsx index c0f8c8cc48da4..c5f05afde9c62 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.tsx +++ b/x-pack/plugins/security_solution/public/common/links/links.test.tsx @@ -432,9 +432,9 @@ describe('Security links', () => { describe('hasCapabilities', () => { const siemShow = 'siem.show'; - const createCases = 'securitySolutionCases.create_cases'; - const readCases = 'securitySolutionCases.read_cases'; - const pushCases = 'securitySolutionCases.push_cases'; + const createCases = 'securitySolutionCasesV2.create_cases'; + const readCases = 'securitySolutionCasesV2.read_cases'; + const pushCases = 'securitySolutionCasesV2.push_cases'; it('returns false when capabilities is an empty array', () => { expect(hasCapabilities(createCapabilities(), [])).toBeFalsy(); @@ -461,7 +461,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { create_cases: false }, + securitySolutionCasesV2: { create_cases: false }, }), [siemShow, createCases] ) @@ -473,7 +473,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: false }, - securitySolutionCases: { create_cases: true }, + securitySolutionCasesV2: { create_cases: true }, }), [siemShow, createCases] ) @@ -485,7 +485,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { create_cases: false }, + securitySolutionCasesV2: { create_cases: false }, }), [readCases, createCases] ) @@ -497,7 +497,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { read_cases: true, create_cases: true }, + securitySolutionCasesV2: { read_cases: true, create_cases: true }, }), [[readCases, createCases]] ) @@ -509,7 +509,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: false }, - securitySolutionCases: { read_cases: false, create_cases: true }, + securitySolutionCasesV2: { read_cases: false, create_cases: true }, }), [siemShow, [readCases, createCases]] ) @@ -521,7 +521,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { read_cases: false, create_cases: true }, + securitySolutionCasesV2: { read_cases: false, create_cases: true }, }), [siemShow, [readCases, createCases]] ) @@ -533,7 +533,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { read_cases: false, create_cases: true, push_cases: false }, + securitySolutionCasesV2: { read_cases: false, create_cases: true, push_cases: false }, }), [ [siemShow, pushCases], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index bdef9cd84c8f6..fa14fc317a78a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -88,6 +88,8 @@ jest.mock('../../../../common/lib/kibana', () => { update: true, delete: true, push: true, + createComment: true, + reopenCase: true, }), getRuleIdFromEvent: jest.fn(), }, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 60a19f005c53e..8ddcd34f092f0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -142,7 +142,7 @@ export const useAddToCaseActions = ({ const addToCaseActionItems: AlertTableContextMenuItem[] = useMemo(() => { if ( (isActiveTimelines || isInDetections) && - userCasesPermissions.create && + userCasesPermissions.createComment && userCasesPermissions.read && isAlert ) { @@ -169,14 +169,14 @@ export const useAddToCaseActions = ({ } return []; }, [ + isActiveTimelines, + isInDetections, + userCasesPermissions.createComment, + userCasesPermissions.read, + isAlert, ariaLabel, handleAddToExistingCaseClick, handleAddToNewCaseClick, - userCasesPermissions.create, - userCasesPermissions.read, - isInDetections, - isActiveTimelines, - isAlert, ]); return { diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts index 64fd3279d18cb..b5c524255509f 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts @@ -15,7 +15,7 @@ export const API_AUTH = Object.freeze({ export const COMMON_API_HEADERS = Object.freeze({ 'kbn-xsrf': 'cypress', 'x-elastic-internal-origin': 'security-solution', - 'Elastic-Api-Version': '2023-10-31', + 'elastic-api-version': '2023-10-31', }); export const waitForPageToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index e785e58435432..fce22635f3f64 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -95,8 +95,8 @@ const DataQualityComponent: React.FC = () => { const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const canUserCreateAndReadCases = useCallback( - () => userCasesPermissions.create && userCasesPermissions.read, - [userCasesPermissions.create, userCasesPermissions.read] + () => userCasesPermissions.createComment && userCasesPermissions.read, + [userCasesPermissions.createComment, userCasesPermissions.read] ); const createCaseFlyout = cases.hooks.useCasesAddToNewCaseFlyout({ diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index b20e645d71c2c..b74d0cffdc88d 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -346,7 +346,7 @@ export class Plugin implements IPlugin ({ status: AppStatus.inaccessible, visibleIn: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx index 25eef44d1469c..793cd12f99451 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx @@ -89,7 +89,7 @@ describe('TimelineModalHeader', () => { cases: { helpers: { canUseCases: jest.fn().mockReturnValue({ - create: true, + createComment: true, read: true, }), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx index 7eccb11a35312..e42e856b9ca74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx @@ -169,7 +169,7 @@ export const TimelineModalHeader = React.memo( isDisabled={isInspectDisabled} /> - {userCasesPermissions.create && userCasesPermissions.read ? ( + {userCasesPermissions.createComment && userCasesPermissions.read ? ( <> diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts index a1f3585ffcdc7..85cadf5aa65d4 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts @@ -55,7 +55,7 @@ export const getEndpointOperationsAnalyst: () => Omit = () => { fleet: ['all'], fleetv2: ['all'], osquery: ['all'], - securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], builtinAlerts: ['all'], siem: [ 'all', diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts index 4ed5f91df77dd..d57ca059de994 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts @@ -37,7 +37,7 @@ export const getNoResponseActionsRole: () => Omit = () => ({ advancedSettings: ['all'], dev_tools: ['all'], fleet: ['all'], - generalCases: ['all'], + generalCasesV2: ['all'], indexPatterns: ['all'], osquery: ['all'], savedObjectsManagement: ['all'], diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts index c2275ebbcee5f..29df069020561 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts @@ -26,6 +26,11 @@ jest.mock('@kbn/security-solution-features/product_features', () => ({ baseKibanaSubFeatureIds: [], subFeaturesMap: new Map(), })), + getCasesV2Feature: jest.fn(() => ({ + baseKibanaFeature: {}, + baseKibanaSubFeatureIds: [], + subFeaturesMap: new Map(), + })), getAssistantFeature: jest.fn(() => ({ baseKibanaFeature: {}, baseKibanaSubFeatureIds: [], diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts index 8d274a30ca3c9..768228f319b24 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts @@ -44,6 +44,7 @@ jest.mock('@kbn/security-solution-features/product_features', () => ({ getAttackDiscoveryFeature: () => mockGetFeature(), getAssistantFeature: () => mockGetFeature(), getCasesFeature: () => mockGetFeature(), + getCasesV2Feature: () => mockGetFeature(), getSecurityFeature: () => mockGetFeature(), })); @@ -56,8 +57,8 @@ describe('ProductFeaturesService', () => { const experimentalFeatures = {} as ExperimentalFeatures; new ProductFeaturesService(loggerMock.create(), experimentalFeatures); - expect(mockGetFeature).toHaveBeenCalledTimes(4); - expect(MockedProductFeatures).toHaveBeenCalledTimes(4); + expect(mockGetFeature).toHaveBeenCalledTimes(5); + expect(MockedProductFeatures).toHaveBeenCalledTimes(5); }); it('should init all ProductFeatures when initialized', () => { diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts index 86928ff905545..2901734527a93 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts @@ -20,6 +20,7 @@ import { getAttackDiscoveryFeature, getCasesFeature, getSecurityFeature, + getCasesV2Feature, } from '@kbn/security-solution-features/product_features'; import type { RecursiveReadonly } from '@kbn/utility-types'; import type { ExperimentalFeatures } from '../../../common'; @@ -35,6 +36,7 @@ export const API_ACTION_PREFIX = `${APP_ID}-`; export class ProductFeaturesService { private securityProductFeatures: ProductFeatures; private casesProductFeatures: ProductFeatures; + private casesProductV2Features: ProductFeatures; private securityAssistantProductFeatures: ProductFeatures; private attackDiscoveryProductFeatures: ProductFeatures; private productFeatures?: Set; @@ -59,6 +61,7 @@ export class ProductFeaturesService { apiTags: casesApiTags, savedObjects: { files: filesSavedObjectTypes }, }); + this.casesProductFeatures = new ProductFeatures( this.logger, casesFeature.subFeaturesMap, @@ -66,6 +69,19 @@ export class ProductFeaturesService { casesFeature.baseKibanaSubFeatureIds ); + const casesV2Feature = getCasesV2Feature({ + uiCapabilities: casesUiCapabilities, + apiTags: casesApiTags, + savedObjects: { files: filesSavedObjectTypes }, + }); + + this.casesProductV2Features = new ProductFeatures( + this.logger, + casesV2Feature.subFeaturesMap, + casesV2Feature.baseKibanaFeature, + casesV2Feature.baseKibanaSubFeatureIds + ); + const assistantFeature = getAssistantFeature(this.experimentalFeatures); this.securityAssistantProductFeatures = new ProductFeatures( this.logger, @@ -86,6 +102,7 @@ export class ProductFeaturesService { public init(featuresSetup: FeaturesPluginSetup) { this.securityProductFeatures.init(featuresSetup); this.casesProductFeatures.init(featuresSetup); + this.casesProductV2Features.init(featuresSetup); this.securityAssistantProductFeatures.init(featuresSetup); this.attackDiscoveryProductFeatures.init(featuresSetup); } @@ -96,6 +113,7 @@ export class ProductFeaturesService { const casesProductFeaturesConfig = configurator.cases(); this.casesProductFeatures.setConfig(casesProductFeaturesConfig); + this.casesProductV2Features.setConfig(casesProductFeaturesConfig); const securityAssistantProductFeaturesConfig = configurator.securityAssistant(); this.securityAssistantProductFeatures.setConfig(securityAssistantProductFeaturesConfig); @@ -124,6 +142,7 @@ export class ProductFeaturesService { return ( this.securityProductFeatures.isActionRegistered(action) || this.casesProductFeatures.isActionRegistered(action) || + this.casesProductV2Features.isActionRegistered(action) || this.securityAssistantProductFeatures.isActionRegistered(action) || this.attackDiscoveryProductFeatures.isActionRegistered(action) ); diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx index 7cf41aac902a6..d498565dd3908 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx @@ -26,7 +26,7 @@ describe('AddToExistingCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -51,7 +51,7 @@ describe('AddToExistingCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -85,7 +85,7 @@ describe('AddToExistingCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: false, + createComment: false, update: false, }), }, diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx index 3baedf85b5b7e..a92a08d10c571 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx @@ -26,7 +26,7 @@ describe('AddToNewCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -51,7 +51,7 @@ describe('AddToNewCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -86,7 +86,7 @@ describe('AddToNewCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: false, + createComment: false, update: false, }), }, diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx index a43efebe98391..8e2f5d3d96a25 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx @@ -36,7 +36,7 @@ describe('useCasePermission', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -60,7 +60,7 @@ describe('useCasePermission', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: false, + createComment: false, update: true, }), }, @@ -84,7 +84,7 @@ describe('useCasePermission', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts index f1a1079c23af1..89e35b8074811 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts @@ -24,7 +24,7 @@ export const useCaseDisabled = (indicatorName: string): boolean => { // disable the item if there is no indicator name or if the user doesn't have the right permission // in the case's attachment, the indicator name is the link to open the flyout const invalidIndicatorName: boolean = indicatorName === EMPTY_VALUE; - const hasPermission: boolean = permissions.create && permissions.update; + const hasPermission: boolean = permissions.createComment && permissions.update; return invalidIndicatorName || !hasPermission; }; diff --git a/x-pack/test/api_integration/apis/cases/common/roles.ts b/x-pack/test/api_integration/apis/cases/common/roles.ts index 5c3e7025900fd..21ad6943ba0df 100644 --- a/x-pack/test/api_integration/apis/cases/common/roles.ts +++ b/x-pack/test/api_integration/apis/cases/common/roles.ts @@ -111,6 +111,31 @@ export const secAll: Role = { }, }; +export const secCasesV2All: Role = { + name: 'sec_cases_v2_all_role_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionCasesV2: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + export const secAllSpace1: Role = { name: 'sec_all_role_space1_api_int', privileges: { @@ -384,6 +409,31 @@ export const casesAll: Role = { }, }; +export const casesV2All: Role = { + name: 'cases_v2_all_role_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + generalCasesV2: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + }, + ], + }, +}; + export const casesRead: Role = { name: 'cases_read_role_api_int', privileges: { @@ -508,6 +558,31 @@ export const obsCasesAll: Role = { }, }; +export const obsCasesV2All: Role = { + name: 'obs_cases_v2_all_role_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + observabilityCasesV2: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + }, + ], + }, +}; + export const obsCasesRead: Role = { name: 'obs_cases_read_role_api_int', privileges: { @@ -537,6 +612,7 @@ export const roles = [ secAllCasesOnlyReadDelete, secAllCasesNoDelete, secAll, + secCasesV2All, secAllSpace1, secAllCasesRead, secAllCasesNone, @@ -548,10 +624,12 @@ export const roles = [ casesOnlyReadDelete, casesNoDelete, casesAll, + casesV2All, casesRead, obsCasesOnlyDelete, obsCasesOnlyReadDelete, obsCasesNoDelete, obsCasesAll, + obsCasesV2All, obsCasesRead, ]; diff --git a/x-pack/test/api_integration/apis/cases/common/users.ts b/x-pack/test/api_integration/apis/cases/common/users.ts index 6cf938dcb0740..a64b9767498fb 100644 --- a/x-pack/test/api_integration/apis/cases/common/users.ts +++ b/x-pack/test/api_integration/apis/cases/common/users.ts @@ -8,16 +8,19 @@ import { User } from '../../../../cases_api_integration/common/lib/authentication/types'; import { casesAll, + casesV2All, casesNoDelete, casesOnlyDelete, casesOnlyReadDelete, casesRead, obsCasesAll, + obsCasesV2All, obsCasesNoDelete, obsCasesOnlyDelete, obsCasesOnlyReadDelete, obsCasesRead, secAll, + secCasesV2All, secAllCasesNoDelete, secAllCasesNone, secAllCasesOnlyDelete, @@ -58,6 +61,12 @@ export const secAllUser: User = { roles: [secAll.name], }; +export const secCasesV2AllUser: User = { + username: 'sec_cases_v2_all_user_api_int', + password: 'password', + roles: [secCasesV2All.name], +}; + export const secAllSpace1User: User = { username: 'sec_all_space1_user_api_int', password: 'password', @@ -128,6 +137,12 @@ export const casesAllUser: User = { roles: [casesAll.name], }; +export const casesV2AllUser: User = { + username: 'cases_v2_all_user_api_int', + password: 'password', + roles: [casesV2All.name], +}; + export const casesReadUser: User = { username: 'cases_read_user_api_int', password: 'password', @@ -162,6 +177,12 @@ export const obsCasesAllUser: User = { roles: [obsCasesAll.name], }; +export const obsCasesV2AllUser: User = { + username: 'obs_cases_v2_all_user_api_int', + password: 'password', + roles: [obsCasesV2All.name], +}; + export const obsCasesReadUser: User = { username: 'obs_cases_read_user_api_int', password: 'password', @@ -189,6 +210,7 @@ export const users = [ secAllCasesOnlyReadDeleteUser, secAllCasesNoDeleteUser, secAllUser, + secCasesV2AllUser, secAllSpace1User, secAllCasesReadUser, secAllCasesNoneUser, @@ -200,11 +222,13 @@ export const users = [ casesOnlyReadDeleteUser, casesNoDeleteUser, casesAllUser, + casesV2AllUser, casesReadUser, obsCasesOnlyDeleteUser, obsCasesOnlyReadDeleteUser, obsCasesNoDeleteUser, obsCasesAllUser, + obsCasesV2AllUser, obsCasesReadUser, obsSecCasesAllUser, obsSecCasesReadUser, diff --git a/x-pack/test/api_integration/apis/cases/privileges.ts b/x-pack/test/api_integration/apis/cases/privileges.ts index 96a8970adeeee..53a1767f5c1a7 100644 --- a/x-pack/test/api_integration/apis/cases/privileges.ts +++ b/x-pack/test/api_integration/apis/cases/privileges.ts @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { APP_ID as CASES_APP_ID } from '@kbn/cases-plugin/common/constants'; +import { AttachmentType } from '@kbn/cases-plugin/common'; +import { CaseStatuses, UserCommentAttachmentPayload } from '@kbn/cases-plugin/common/types/domain'; import { APP_ID as SECURITY_SOLUTION_APP_ID } from '@kbn/security-solution-plugin/common/constants'; import { observabilityFeatureId as OBSERVABILITY_APP_ID } from '@kbn/observability-plugin/common'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -16,12 +18,16 @@ import { deleteAllCaseItems, deleteCases, getCase, + createComment, + updateCaseStatus, } from '../../../cases_api_integration/common/lib/api'; import { casesAllUser, + casesV2AllUser, casesNoDeleteUser, casesOnlyDeleteUser, obsCasesAllUser, + obsCasesV2AllUser, obsCasesNoDeleteUser, obsCasesOnlyDeleteUser, secAllCasesNoDeleteUser, @@ -29,6 +35,7 @@ import { secAllCasesOnlyDeleteUser, secAllCasesReadUser, secAllUser, + secCasesV2AllUser, secReadCasesAllUser, secReadCasesNoneUser, secReadCasesReadUser, @@ -48,10 +55,13 @@ export default ({ getService }: FtrProviderContext): void => { for (const { user, owner } of [ { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, { user: secReadCasesAllUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, { user: casesNoDeleteUser, owner: CASES_APP_ID }, { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, { user: obsCasesNoDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${user.username} with role(s) ${user.roles.join()} can create a case`, async () => { @@ -68,8 +78,10 @@ export default ({ getService }: FtrProviderContext): void => { { user: secReadCasesReadUser, owner: SECURITY_SOLUTION_APP_ID }, { user: secReadUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, { user: casesNoDeleteUser, owner: CASES_APP_ID }, { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, { user: obsCasesNoDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${user.username} with role(s) ${user.roles.join()} can get a case`, async () => { @@ -125,10 +137,13 @@ export default ({ getService }: FtrProviderContext): void => { for (const { user, owner } of [ { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, { user: secAllCasesOnlyDeleteUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, { user: casesOnlyDeleteUser, owner: CASES_APP_ID }, { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, { user: obsCasesOnlyDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${user.username} with role(s) ${user.roles.join()} can delete a case`, async () => { @@ -160,5 +175,60 @@ export default ({ getService }: FtrProviderContext): void => { }); }); } + + for (const { user, owner } of [ + { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, + { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, + ]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can reopen a case`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner })); + await updateCaseStatus({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + status: 'closed' as CaseStatuses, + version: '2', + expectedHttpCode: 200, + auth: { user, space: null }, + }); + + await updateCaseStatus({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + status: 'open' as CaseStatuses, + version: '3', + expectedHttpCode: 200, + auth: { user, space: null }, + }); + }); + } + + for (const { user, owner } of [ + { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, + { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, + ]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can add comments`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner })); + const comment: UserCommentAttachmentPayload = { + comment: 'test', + owner, + type: AttachmentType.user, + }; + await createComment({ + params: comment, + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + expectedHttpCode: 200, + auth: { user, space: null }, + }); + }); + } }); }; diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 547fd12a54203..4ded1782c9086 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -111,7 +111,7 @@ export default function ({ getService }: FtrProviderContext) { 'guidedOnboardingFeature', 'monitoring', 'observabilityAIAssistant', - 'observabilityCases', + 'observabilityCasesV2', 'savedObjectsManagement', 'savedQueryManagement', 'savedObjectsTagging', @@ -119,7 +119,7 @@ export default function ({ getService }: FtrProviderContext) { 'apm', 'stackAlerts', 'canvas', - 'generalCases', + 'generalCasesV2', 'infrastructure', 'inventory', 'logs', @@ -133,7 +133,7 @@ export default function ({ getService }: FtrProviderContext) { 'slo', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', - 'securitySolutionCases', + 'securitySolutionCasesV2', 'fleet', 'fleetv2', ].sort() @@ -161,7 +161,7 @@ export default function ({ getService }: FtrProviderContext) { 'guidedOnboardingFeature', 'monitoring', 'observabilityAIAssistant', - 'observabilityCases', + 'observabilityCasesV2', 'savedObjectsManagement', 'savedQueryManagement', 'savedObjectsTagging', @@ -169,7 +169,7 @@ export default function ({ getService }: FtrProviderContext) { 'apm', 'stackAlerts', 'canvas', - 'generalCases', + 'generalCasesV2', 'infrastructure', 'inventory', 'logs', @@ -183,7 +183,7 @@ export default function ({ getService }: FtrProviderContext) { 'slo', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', - 'securitySolutionCases', + 'securitySolutionCasesV2', 'fleet', 'fleetv2', ]; diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 1ff986829415b..b269aef6ae1cc 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -30,6 +30,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + generalCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityCases: [ 'all', 'read', @@ -38,6 +48,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + observabilityCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -89,6 +109,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + securitySolutionCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 57a166ef4be9d..a97ee360062c0 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -32,7 +32,9 @@ export default function ({ getService }: FtrProviderContext) { graph: ['all', 'read', 'minimal_all', 'minimal_read'], maps: ['all', 'read', 'minimal_all', 'minimal_read'], generalCases: ['all', 'read', 'minimal_all', 'minimal_read'], + generalCasesV2: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read'], + observabilityCasesV2: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], canvas: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -47,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) { securitySolutionAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read'], + securitySolutionCasesV2: ['all', 'read', 'minimal_all', 'minimal_read'], searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'], fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'], fleet: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -112,6 +115,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + generalCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityCases: [ 'all', 'read', @@ -120,6 +133,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + observabilityCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -177,6 +200,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + securitySolutionCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts b/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts index a39796f1f4448..2a85320d14edf 100644 --- a/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts +++ b/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts @@ -37,7 +37,7 @@ const secAll: Role = { { feature: { siem: ['all'], - securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -68,7 +68,7 @@ const secRead: Role = { { feature: { siem: ['read'], - securitySolutionCases: ['read'], + securitySolutionCasesV2: ['read'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/test/cases_api_integration/common/lib/api/case.ts b/x-pack/test/cases_api_integration/common/lib/api/case.ts index 759e2de460460..9f03a62032c89 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/case.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/case.ts @@ -6,8 +6,12 @@ */ import { CASES_URL } from '@kbn/cases-plugin/common'; -import { Case } from '@kbn/cases-plugin/common/types/domain'; -import { CasePostRequest, CasesFindResponse } from '@kbn/cases-plugin/common/types/api'; +import { Case, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; +import { + CasePostRequest, + CasesFindResponse, + CasePatchRequest, +} from '@kbn/cases-plugin/common/types/api'; import type SuperTest from 'supertest'; import { ToolingLog } from '@kbn/tooling-log'; import { User } from '../authentication/types'; @@ -91,3 +95,32 @@ export const deleteCases = async ({ return body; }; + +export const updateCaseStatus = async ({ + supertest, + caseId, + version = '2', + status = 'open' as CaseStatuses, + expectedHttpCode = 204, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.Agent; + caseId: string; + version?: string; + status?: CaseStatuses; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}) => { + const updateRequest: CasePatchRequest = { + status, + version, + id: caseId, + }; + + const { body: updatedCase } = await supertest + .patch(`/api/cases/${caseId}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'xxx') + .send(updateRequest); + return updatedCase; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts index d5969606dc414..a3b8b71d2fc97 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts @@ -7,31 +7,28 @@ import { Role } from './types'; +const defaultElasticsearchPrivileges = { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, +}; + export const noKibanaPrivileges: Role = { name: 'no_kibana_privileges', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, }, }; export const noCasesPrivilegesSpace1: Role = { name: 'no_cases_kibana_privileges', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -47,14 +44,7 @@ export const noCasesPrivilegesSpace1: Role = { export const noCasesConnectors: Role = { name: 'no_cases_connectors', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -71,14 +61,7 @@ export const noCasesConnectors: Role = { export const globalRead: Role = { name: 'global_read', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -96,14 +79,7 @@ export const globalRead: Role = { export const testDisabledPluginAll: Role = { name: 'test_disabled_plugin_all', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -121,14 +97,7 @@ export const testDisabledPluginAll: Role = { export const securitySolutionOnlyAll: Role = { name: 'sec_only_all', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -145,14 +114,7 @@ export const securitySolutionOnlyAll: Role = { export const securitySolutionOnlyDelete: Role = { name: 'sec_only_delete', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -169,18 +131,11 @@ export const securitySolutionOnlyDelete: Role = { export const securitySolutionOnlyReadDelete: Role = { name: 'sec_only_read_delete', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { - securitySolutionFixture: ['read', 'cases_delete'], + securitySolutionFixture: ['minimal_read', 'cases_delete'], actions: ['all'], actionsSimulators: ['all'], }, @@ -193,14 +148,58 @@ export const securitySolutionOnlyReadDelete: Role = { export const securitySolutionOnlyNoDelete: Role = { name: 'sec_only_no_delete', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], + ...defaultElasticsearchPrivileges, + kibana: [ + { + feature: { + securitySolutionFixture: ['minimal_all'], + actions: ['all'], + actionsSimulators: ['all'], }, - ], - }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyCreateComment: Role = { + name: 'sec_only_create_comment', + privileges: { + ...defaultElasticsearchPrivileges, + kibana: [ + { + feature: { + securitySolutionFixture: ['create_comment'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadCreateComment: Role = { + name: 'sec_only_read_create_comment', + privileges: { + ...defaultElasticsearchPrivileges, + kibana: [ + { + feature: { + securitySolutionFixture: ['minimal_read', 'create_comment'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyNoCreateComment: Role = { + name: 'sec_only_no_create_comment', + privileges: { + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -217,14 +216,7 @@ export const securitySolutionOnlyNoDelete: Role = { export const securitySolutionOnlyRead: Role = { name: 'sec_only_read', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -241,14 +233,7 @@ export const securitySolutionOnlyRead: Role = { export const securitySolutionOnlyReadAlerts: Role = { name: 'sec_only_read_alerts', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -282,14 +267,7 @@ export const securitySolutionOnlyReadNoIndexAlerts: Role = { export const observabilityOnlyAll: Role = { name: 'obs_only_all', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -306,14 +284,7 @@ export const observabilityOnlyAll: Role = { export const observabilityOnlyRead: Role = { name: 'obs_only_read', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -353,14 +324,7 @@ export const observabilityOnlyReadAlerts: Role = { export const securitySolutionOnlyAllSpacesRole: Role = { name: 'sec_only_all_spaces', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -377,14 +341,7 @@ export const securitySolutionOnlyAllSpacesRole: Role = { export const onlyActions: Role = { name: 'only_actions', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -408,6 +365,9 @@ export const roles = [ securitySolutionOnlyDelete, securitySolutionOnlyReadDelete, securitySolutionOnlyNoDelete, + securitySolutionOnlyCreateComment, + securitySolutionOnlyReadCreateComment, + securitySolutionOnlyNoCreateComment, observabilityOnlyAll, observabilityOnlyRead, observabilityOnlyReadAlerts, diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts index 9bf90665eb181..01489d878526c 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts @@ -23,6 +23,9 @@ import { securitySolutionOnlyReadDelete, noCasesConnectors as noCasesConnectorRole, onlyActions as onlyActionsRole, + securitySolutionOnlyCreateComment, + securitySolutionOnlyNoCreateComment, + securitySolutionOnlyReadCreateComment, } from './roles'; import { User } from './types'; @@ -62,6 +65,24 @@ export const secOnlyNoDelete: User = { roles: [securitySolutionOnlyNoDelete.name], }; +export const secOnlyCreateComment: User = { + username: 'sec_only_create_comment', + password: 'sec_only_create_comment', + roles: [securitySolutionOnlyCreateComment.name], +}; + +export const secOnlyReadCreateComment: User = { + username: 'sec_only_read_create_comment', + password: 'sec_only_read_create_comment', + roles: [securitySolutionOnlyReadCreateComment.name], +}; + +export const secOnlyNoCreateComment: User = { + username: 'sec_only_no_create_comment', + password: 'sec_only_no_create_comment', + roles: [securitySolutionOnlyNoCreateComment.name], +}; + export const secOnlyRead: User = { username: 'sec_only_read', password: 'sec_only_read', @@ -159,6 +180,9 @@ export const users = [ secOnlyDelete, secOnlyReadDelete, secOnlyNoDelete, + secOnlyCreateComment, + secOnlyReadCreateComment, + secOnlyNoCreateComment, obsOnly, obsOnlyRead, obsOnlyReadAlerts, diff --git a/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts b/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts index e2c7cf4d88411..34f4c6d7423c0 100644 --- a/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts +++ b/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts @@ -115,6 +115,52 @@ export class FixturePlugin implements Plugin { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('createComment subprivilege', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('user comments', () => { + it('should not create user comments', async () => { + // No privileges + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnlyNoCreateComment, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + // Create + for (const scenario of [ + { user: secOnlyReadCreateComment, space: 'space1' }, + { user: secOnlyCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should create user comments`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: scenario, + expectedHttpCode: 200, + }); + }); + } + + // Update + it('should update comment without createComment privileges', async () => { + // Note: Not ideal behavior. A user unable to create should not be able to update, + // but it is a concession until the privileges are properly broken apart. + const commentUpdate = 'Heres an update because I do not want to make a new comment!'; + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space1' }, + }); + + const updatedCommentCase = await updateComment({ + supertest, + caseId: postedCase.id, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: commentUpdate, + type: AttachmentType.user, + owner: 'securitySolutionFixture', + }, + }); + + const userActions = await getCaseUserActions({ + supertest, + caseID: postedCase.id, + auth: { user: superUser, space: 'space1' }, + }); + const commentUserAction = userActions[2]; + + expect(userActions.length).to.eql(3); + expect(commentUserAction.type).to.eql('comment'); + expect(commentUserAction.action).to.eql('update'); + expect(commentUserAction.comment_id).to.eql(updatedCommentCase.comments![0].id); + expect(commentUserAction.payload).to.eql({ + comment: { + comment: commentUpdate, + type: AttachmentType.user, + owner: 'securitySolutionFixture', + }, + }); + }); + + // Update + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not update user comments`, async () => { + const commentUpdate = 'Heres an update because I do not want to make a new comment!'; + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space1' }, + }); + + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: commentUpdate, + type: AttachmentType.user, + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 403, + }); + }); + } + }); + + describe('alerts', () => { + it('should not attach alerts to the case', async () => { + // No privileges + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + // Create + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should attach alerts`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + auth: scenario, + expectedHttpCode: 200, + }); + }); + } + + // Delete + for (const scenario of [ + { user: secOnlyNoCreateComment, space: 'space1' }, + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not delete attached alerts`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + auth: { user: superUser, space: 'space1' }, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + expectedHttpCode: 403, + }); + }); + } + }); + + describe('files', () => { + it('should not attach files to the case', async () => { + // No privileges + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + params: getFilesAttachmentReq(), + expectedHttpCode: 403, + }); + }); + + // Create + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should attach files`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + const caseWithAttachments = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + params: getFilesAttachmentReq(), + expectedHttpCode: 200, + }); + + const fileAttachment = + caseWithAttachments.comments![0] as ExternalReferenceSOAttachmentPayload; + + expect(caseWithAttachments.totalComment).to.be(1); + expect(fileAttachment.externalReferenceMetadata).to.eql(fileAttachmentMetadata); + }); + } + + // Delete + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not delete attached files`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: superUser, space: 'space1' }, + params: getFilesAttachmentReq(), + expectedHttpCode: 200, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + expectedHttpCode: 403, + }); + }); + } + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts index 75388fe0bfe19..22ac95050cffa 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts @@ -24,6 +24,7 @@ import { } from '../../../common/lib/api'; import { superUser, + secOnlyCreateComment, secOnlyDelete, secOnlyNoDelete, } from '../../../common/lib/authentication/users'; @@ -306,7 +307,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: caseInfo.id, params: postCommentUserReq, - auth: { user: secOnlyNoDelete, space: 'space1' }, + auth: { user: secOnlyCreateComment, space: 'space1' }, }); await deleteComment({ diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index c1038eb964313..3112dfab7ec66 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -36,6 +36,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { loadTestFile(require.resolve('./attachments_framework/registered_persistable_state_trial')); // sub privileges are only available with a license above basic loadTestFile(require.resolve('./delete_sub_privilege')); + loadTestFile(require.resolve('./create_comment_sub_privilege.ts')); loadTestFile(require.resolve('./user_profiles/get_current')); // Internal routes diff --git a/x-pack/test/functional/services/ml/security_common.ts b/x-pack/test/functional/services/ml/security_common.ts index 6d9aee298beaa..05738e664796d 100644 --- a/x-pack/test/functional/services/ml/security_common.ts +++ b/x-pack/test/functional/services/ml/security_common.ts @@ -150,7 +150,7 @@ export function MachineLearningSecurityCommonProvider({ getService }: FtrProvide savedObjectsManagement: ['all'], advancedSettings: ['all'], indexPatterns: ['all'], - generalCases: ['all'], + generalCasesV2: ['all'], ml: ['none'], }, spaces: ['*'], @@ -179,7 +179,7 @@ export function MachineLearningSecurityCommonProvider({ getService }: FtrProvide savedObjectsManagement: ['all'], advancedSettings: ['all'], indexPatterns: ['all'], - generalCases: ['all'], + generalCasesV2: ['all'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/services/observability/users.ts b/x-pack/test/functional/services/observability/users.ts index 0e2915190d126..2386c08a4f90e 100644 --- a/x-pack/test/functional/services/observability/users.ts +++ b/x-pack/test/functional/services/observability/users.ts @@ -58,7 +58,7 @@ export function ObservabilityUsersProvider({ getPageObject, getService }: FtrPro */ const defineBasicObservabilityRole = ( features: Partial<{ - observabilityCases: string[]; + observabilityCasesV2: string[]; apm: string[]; logs: string[]; infrastructure: string[]; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts b/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts index f06c8745d6df6..0e8cb455ad299 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts @@ -25,7 +25,7 @@ export const casesReadDelete: Role = { kibana: [ { feature: { - generalCases: ['minimal_read', 'cases_delete'], + generalCasesV2: ['minimal_read', 'cases_delete'], actions: ['all'], actionsSimulators: ['all'], }, @@ -49,7 +49,7 @@ export const casesNoDelete: Role = { kibana: [ { feature: { - generalCases: ['minimal_all'], + generalCasesV2: ['minimal_all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -73,7 +73,7 @@ export const casesAll: Role = { kibana: [ { feature: { - generalCases: ['all'], + generalCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx b/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx index 31c0b25f51e94..6ab6a1cce3610 100644 --- a/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx +++ b/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx @@ -42,6 +42,8 @@ const permissions = { push: true, connectors: true, settings: true, + createComment: true, + reopenCase: true, }; const attachments = [{ type: AttachmentType.user as const, comment: 'test' }]; diff --git a/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts b/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts index a71c83a5221c3..81fb1d23ba33e 100644 --- a/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts +++ b/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts @@ -43,7 +43,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['all'], + observabilityCasesV2: ['all'], logs: ['all'], }) ); @@ -96,7 +96,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['read'], + observabilityCasesV2: ['read'], logs: ['all'], }) ); diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts index 33b2ad3ba329a..ccb4264147523 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts @@ -29,7 +29,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { before(async () => { await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['all'], + observabilityCasesV2: ['all'], logs: ['all'], }) ); @@ -75,7 +75,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { before(async () => { await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['read'], + observabilityCasesV2: ['read'], logs: ['all'], }) ); diff --git a/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts b/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts index ac6343f8e7170..90fc09af9c6ad 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts @@ -33,7 +33,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { before(async () => { await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['all'], + observabilityCasesV2: ['all'], logs: ['all'], }) ); diff --git a/x-pack/test/security_api_integration/tests/features/deprecated_features.ts b/x-pack/test/security_api_integration/tests/features/deprecated_features.ts index 6e868fc5946ec..29135ff2440b2 100644 --- a/x-pack/test/security_api_integration/tests/features/deprecated_features.ts +++ b/x-pack/test/security_api_integration/tests/features/deprecated_features.ts @@ -181,6 +181,9 @@ export default function ({ getService }: FtrProviderContext) { "case_3_feature_a", "case_4_feature_a", "case_4_feature_b", + "generalCases", + "observabilityCases", + "securitySolutionCases", ] `); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts index cb3e73011386b..0800c2b610a27 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts @@ -46,8 +46,9 @@ describe.skip('Export timelines', { tags: ['@ess', '@serverless'] }, () => { /** * TODO: Good candidate for converting to a jest Test * https://github.com/elastic/kibana/issues/195612 + * Failing: https://github.com/elastic/kibana/issues/187550 */ - it('should export custom timeline(s)', function () { + it.skip('should export custom timeline(s)', function () { cy.log('Export a custom timeline via timeline actions'); exportTimeline(this.timelineId1); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts b/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts index 7f2d0dea8b545..bbbaaa1e240a6 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts @@ -66,6 +66,7 @@ export const secAll: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -99,6 +100,7 @@ export const secReadCasesAll: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -132,6 +134,7 @@ export const secAllCasesOnlyReadDelete: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['cases_read', 'cases_delete'], + securitySolutionCasesV2: ['cases_read', 'cases_delete'], actions: ['all'], actionsSimulators: ['all'], }, @@ -165,6 +168,7 @@ export const secAllCasesNoDelete: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['minimal_all'], + securitySolutionCasesV2: ['minimal_all'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/test/spaces_api_integration/common/suites/create.ts b/x-pack/test/spaces_api_integration/common/suites/create.ts index 795d177805f89..d84945fbfe032 100644 --- a/x-pack/test/spaces_api_integration/common/suites/create.ts +++ b/x-pack/test/spaces_api_integration/common/suites/create.ts @@ -81,9 +81,11 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest) 'inventory', 'logs', 'observabilityCases', + 'observabilityCasesV2', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCases', + 'securitySolutionCasesV2', 'siem', 'slo', 'uptime', diff --git a/x-pack/test/spaces_api_integration/common/suites/get_all.ts b/x-pack/test/spaces_api_integration/common/suites/get_all.ts index b90128ab12c70..9d51cbb12e469 100644 --- a/x-pack/test/spaces_api_integration/common/suites/get_all.ts +++ b/x-pack/test/spaces_api_integration/common/suites/get_all.ts @@ -80,9 +80,11 @@ const ALL_SPACE_RESULTS: Space[] = [ 'inventory', 'logs', 'observabilityCases', + 'observabilityCasesV2', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCases', + 'securitySolutionCasesV2', 'siem', 'slo', 'uptime', diff --git a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts index e691f84d7bdc7..4a43c3831627c 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts @@ -66,6 +66,7 @@ export default function ({ getService }: FtrProviderContext) { maintenanceWindow: 0, stackAlerts: 0, generalCases: 0, + generalCasesV2: 0, maps: 2, canvas: 2, ml: 0, @@ -73,6 +74,7 @@ export default function ({ getService }: FtrProviderContext) { fleet: 0, osquery: 0, observabilityCases: 0, + observabilityCasesV2: 0, uptime: 0, slo: 0, infrastructure: 0, @@ -84,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) { searchInferenceEndpoints: 0, siem: 0, securitySolutionCases: 0, + securitySolutionCasesV2: 0, securitySolutionAssistant: 0, securitySolutionAttackDiscovery: 0, discover: 0, diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 2ba14ceb1218c..9db41aecbb612 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -187,6 +187,6 @@ "@kbn/alerting-types", "@kbn/ai-assistant-common", "@kbn/core-deprecations-common", - "@kbn/usage-collection-plugin" + "@kbn/usage-collection-plugin", ] } diff --git a/x-pack/test_serverless/shared/lib/security/default_http_headers.ts b/x-pack/test_serverless/shared/lib/security/default_http_headers.ts index 03c96905d6b06..18293b74ce116 100644 --- a/x-pack/test_serverless/shared/lib/security/default_http_headers.ts +++ b/x-pack/test_serverless/shared/lib/security/default_http_headers.ts @@ -8,4 +8,5 @@ export const STANDARD_HTTP_HEADERS = Object.freeze({ 'kbn-xsrf': 'cypress-creds-via-env', 'x-elastic-internal-origin': 'security-solution', + 'elastic-api-version': '2023-10-31', }); diff --git a/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml b/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml index 22b3fd31c423b..61d3378de4c68 100644 --- a/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml +++ b/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml @@ -493,6 +493,7 @@ soc_manager: - application: "kibana-.kibana" privileges: - feature_ml.read + - feature_generalCases.all - feature_siem.all - feature_siem.read_alerts - feature_siem.crud_alerts @@ -509,6 +510,7 @@ soc_manager: - feature_siem.execute_operations_all - feature_siem.scan_operations_all - feature_securitySolutionCases.all + - feature_observabilityCases.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all From a5831c0d2346d7ea7b95cb0516ff67da46b9d68b Mon Sep 17 00:00:00 2001 From: Samantha Tan <96286575+samantha-t28@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:38:13 -0800 Subject: [PATCH 51/61] fix: Change "Single Account" to "Single Project" in button text (#200327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This pull request resolves [#198879](https://github.com/elastic/kibana/issues/198879), where "Single Account" was incorrectly displayed in the GCP integration screen. The text has been updated to "Single Project" to align with GCP terminology. Additionally, the paragraph above this text was updated to ensure consistency across the UI. ## Motivation Aligning the GCP integration screen with GCP’s correct terminology by replacing "Single Account" with "Single Project" ensures clarity and consistency across the UI. ## Screenshots: **Before**: interface showing "Single Account" Screenshot 2024-11-18 at 1 29 18 PM **After**: updated interface showing "Single Project" Screenshot 2024-11-18 at 1 29 57 PM ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [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) - [ ] ~~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials~~ - [ ] ~~[Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios~~ - [ ] ~~If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~~ - [ ] ~~This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations.~~ - [ ] ~~[Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed~~ - [x] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Maxim Kholod --- .../components/fleet_extensions/policy_template_form.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx index 73d8ed22011dc..9d8deb5b9892d 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx @@ -148,7 +148,7 @@ const getGcpAccountTypeOptions = (isGcpOrgDisabled: boolean): CspRadioGroupProps { id: GCP_SINGLE_ACCOUNT, label: i18n.translate('xpack.csp.fleetIntegration.gcpAccountType.gcpSingleAccountLabel', { - defaultMessage: 'Single Account', + defaultMessage: 'Single Project', }), testId: 'gcpSingleAccountTestId', }, @@ -377,7 +377,7 @@ const GcpAccountTypeSelect = ({ From 8e7799ae7aed6504b234c1779e6d3654fbcc9a32 Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 19 Nov 2024 15:23:20 -0500 Subject: [PATCH 52/61] Removing experimental for the FIPS mode config (#200734) ## Summary Closes https://github.com/elastic/kibana/issues/200718 Remove the `experimental` from the fipsMode config path ## Release note Kibana's FIPS mode is no longer considered experimental ## FIPS Pipeline for this branch https://buildkite.com/elastic/kibana-fips/builds/281 --- .buildkite/scripts/common/env.sh | 2 +- .devcontainer/scripts/env.sh | 4 +-- docs/user/security/fips-140-2.asciidoc | 7 +---- .../src/fips/fips.test.ts | 28 +++++++++---------- .../src/fips/fips.ts | 4 +-- .../src/security_service.test.ts | 6 ++-- .../src/utils/index.ts | 6 ++-- .../src/create_root.ts | 2 +- .../integration_tests/node/migrator.test.ts | 2 +- .../resources/base/bin/kibana-docker | 2 +- .../templates/base/Dockerfile | 4 +-- x-pack/plugins/security/server/config.test.ts | 24 ++++++---------- x-pack/plugins/security/server/config.ts | 6 ++-- .../server/config_deprecations.test.ts | 22 +++++++++++++++ .../security/server/config_deprecations.ts | 3 ++ .../security/server/fips/fips_service.test.ts | 26 ++++++++--------- .../security/server/fips/fips_service.ts | 4 +-- 17 files changed, 79 insertions(+), 73 deletions(-) diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index 511f6ead2d43c..1eb86de0bc030 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -146,7 +146,7 @@ if [[ "${KBN_ENABLE_FIPS:-}" == "true" ]] || is_pr_with_label "ci:enable-fips-ag fi if [[ -f "$KIBANA_DIR/config/kibana.yml" ]]; then - echo -e '\nxpack.security.experimental.fipsMode.enabled: true' >>"$KIBANA_DIR/config/kibana.yml" + echo -e '\nxpack.security.fipsMode.enabled: true' >>"$KIBANA_DIR/config/kibana.yml" fi fi diff --git a/.devcontainer/scripts/env.sh b/.devcontainer/scripts/env.sh index 77c2000663e5f..dccc17130c99c 100755 --- a/.devcontainer/scripts/env.sh +++ b/.devcontainer/scripts/env.sh @@ -9,7 +9,7 @@ setup_fips() { fi if [ -n "$FIPS" ] && [ "$FIPS" = "1" ]; then - sed -i '/xpack.security.experimental.fipsMode.enabled:/ {s/.*/xpack.security.experimental.fipsMode.enabled: true/; t}; $a\xpack.security.experimental.fipsMode.enabled: true' "$KBN_CONFIG_FILE" + sed -i '/xpack.security.fipsMode.enabled:/ {s/.*/xpack.security.fipsMode.enabled: true/; t}; $a\xpack.security.fipsMode.enabled: true' "$KBN_CONFIG_FILE" # Patch node_modules so we can start Kibana in dev mode sed -i 's/hashType = hashType || '\''md5'\'';/hashType = hashType || '\''sha1'\'';/g' "${KBN_DIR}/node_modules/file-loader/node_modules/loader-utils/lib/getHashDigest.js" @@ -21,7 +21,7 @@ setup_fips() { echo "FIPS mode enabled" echo "If manually bootstrapping in FIPS mode use: NODE_OPTIONS='' yarn kbn bootstrap" else - sed -i '/xpack.security.experimental.fipsMode.enabled:/ {s/.*/xpack.security.experimental.fipsMode.enabled: false/; t}; $a\xpack.security.experimental.fipsMode.enabled: false' "$KBN_CONFIG_FILE" + sed -i '/xpack.security.fipsMode.enabled:/ {s/.*/xpack.security.fipsMode.enabled: false/; t}; $a\xpack.security.fipsMode.enabled: false' "$KBN_CONFIG_FILE" fi } diff --git a/docs/user/security/fips-140-2.asciidoc b/docs/user/security/fips-140-2.asciidoc index 2b4b195f38b05..eada7bcc59cc7 100644 --- a/docs/user/security/fips-140-2.asciidoc +++ b/docs/user/security/fips-140-2.asciidoc @@ -29,7 +29,7 @@ For {kib}, adherence to FIPS 140-2 is ensured by: ==== Configuring {kib} for FIPS 140-2 -Apart from setting `xpack.security.experimental.fipsMode.enabled` to `true` in your {kib} config, a number of security related +Apart from setting `xpack.security.fipsMode.enabled` to `true` in your {kib} config, a number of security related settings need to be reviewed and configured in order to run {kib} successfully in a FIPS 140-2 compliant Node.js environment. @@ -56,8 +56,3 @@ As an example, avoid PKCS#12 specific settings such as: * `server.ssl.truststore.path` * `elasticsearch.ssl.keystore.path` * `elasticsearch.ssl.truststore.path` - -===== Limitations - -Configuring {kib} to run in FIPS mode is still considered to be experimental. Not all features are guaranteed to -function as expected. diff --git a/packages/core/security/core-security-server-internal/src/fips/fips.test.ts b/packages/core/security/core-security-server-internal/src/fips/fips.test.ts index ff610493e1322..724f6accd5204 100644 --- a/packages/core/security/core-security-server-internal/src/fips/fips.test.ts +++ b/packages/core/security/core-security-server-internal/src/fips/fips.test.ts @@ -25,26 +25,26 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; describe('fips', () => { let securityConfig: SecurityServiceConfigType; describe('#isFipsEnabled', () => { - it('should return `true` if config.experimental.fipsMode.enabled is `true`', () => { - securityConfig = { experimental: { fipsMode: { enabled: true } } }; + it('should return `true` if config.fipsMode.enabled is `true`', () => { + securityConfig = { fipsMode: { enabled: true } }; expect(isFipsEnabled(securityConfig)).toBe(true); }); - it('should return `false` if config.experimental.fipsMode.enabled is `false`', () => { - securityConfig = { experimental: { fipsMode: { enabled: false } } }; + it('should return `false` if config.fipsMode.enabled is `false`', () => { + securityConfig = { fipsMode: { enabled: false } }; expect(isFipsEnabled(securityConfig)).toBe(false); }); - it('should return `false` if config.experimental.fipsMode.enabled is `undefined`', () => { + it('should return `false` if config.fipsMode.enabled is `undefined`', () => { expect(isFipsEnabled(securityConfig)).toBe(false); }); }); describe('checkFipsConfig', () => { - it('should log an error message if FIPS mode is misconfigured - xpack.security.experimental.fipsMode.enabled true, Nodejs FIPS mode false', async () => { - securityConfig = { experimental: { fipsMode: { enabled: true } } }; + it('should log an error message if FIPS mode is misconfigured - xpack.security.fipsMode.enabled true, Nodejs FIPS mode false', async () => { + securityConfig = { fipsMode: { enabled: true } }; const logger = loggingSystemMock.create().get(); let fipsException: undefined | CriticalError; try { @@ -56,16 +56,16 @@ describe('fips', () => { expect(fipsException).toBeInstanceOf(CriticalError); expect(fipsException!.processExitCode).toBe(78); expect(fipsException!.message).toEqual( - 'Configuration mismatch error. xpack.security.experimental.fipsMode.enabled is set to true and the configured Node.js environment has FIPS disabled' + 'Configuration mismatch error. xpack.security.fipsMode.enabled is set to true and the configured Node.js environment has FIPS disabled' ); }); - it('should log an error message if FIPS mode is misconfigured - xpack.security.experimental.fipsMode.enabled false, Nodejs FIPS mode true', async () => { + it('should log an error message if FIPS mode is misconfigured - xpack.security.fipsMode.enabled false, Nodejs FIPS mode true', async () => { mockGetFipsFn.mockImplementationOnce(() => { return 1; }); - securityConfig = { experimental: { fipsMode: { enabled: false } } }; + securityConfig = { fipsMode: { enabled: false } }; const logger = loggingSystemMock.create().get(); let fipsException: undefined | CriticalError; @@ -77,16 +77,16 @@ describe('fips', () => { expect(fipsException).toBeInstanceOf(CriticalError); expect(fipsException!.processExitCode).toBe(78); expect(fipsException!.message).toEqual( - 'Configuration mismatch error. xpack.security.experimental.fipsMode.enabled is set to false and the configured Node.js environment has FIPS enabled' + 'Configuration mismatch error. xpack.security.fipsMode.enabled is set to false and the configured Node.js environment has FIPS enabled' ); }); - it('should log an info message if FIPS mode is properly configured - xpack.security.experimental.fipsMode.enabled true, Nodejs FIPS mode true', async () => { + it('should log an info message if FIPS mode is properly configured - xpack.security.fipsMode.enabled true, Nodejs FIPS mode true', async () => { mockGetFipsFn.mockImplementationOnce(() => { return 1; }); - securityConfig = { experimental: { fipsMode: { enabled: true } } }; + securityConfig = { fipsMode: { enabled: true } }; const logger = loggingSystemMock.create().get(); try { @@ -113,7 +113,7 @@ describe('fips', () => { return 1; }); - securityConfig = { experimental: { fipsMode: { enabled: true } } }; + securityConfig = { fipsMode: { enabled: true } }; }); afterEach(function () { diff --git a/packages/core/security/core-security-server-internal/src/fips/fips.ts b/packages/core/security/core-security-server-internal/src/fips/fips.ts index 0d9dea9e467fe..5fa47d3afc062 100644 --- a/packages/core/security/core-security-server-internal/src/fips/fips.ts +++ b/packages/core/security/core-security-server-internal/src/fips/fips.ts @@ -12,7 +12,7 @@ import { getFips } from 'crypto'; import { CriticalError } from '@kbn/core-base-server-internal'; import { PKCS12ConfigType, SecurityServiceConfigType } from '../utils'; export function isFipsEnabled(config: SecurityServiceConfigType): boolean { - return config?.experimental?.fipsMode?.enabled ?? false; + return config?.fipsMode?.enabled ?? false; } export function checkFipsConfig( @@ -33,7 +33,7 @@ export function checkFipsConfig( // FIPS must be enabled on both, or, log/error an exit Kibana if (isFipsConfigEnabled !== isNodeRunningWithFipsEnabled) { throw new CriticalError( - `Configuration mismatch error. xpack.security.experimental.fipsMode.enabled is set to ${isFipsConfigEnabled} and the configured Node.js environment has FIPS ${ + `Configuration mismatch error. xpack.security.fipsMode.enabled is set to ${isFipsConfigEnabled} and the configured Node.js environment has FIPS ${ isNodeRunningWithFipsEnabled ? 'enabled' : 'disabled' }`, 'invalidConfig', diff --git a/packages/core/security/core-security-server-internal/src/security_service.test.ts b/packages/core/security/core-security-server-internal/src/security_service.test.ts index 0ff1e59db71ec..d725d062b231e 100644 --- a/packages/core/security/core-security-server-internal/src/security_service.test.ts +++ b/packages/core/security/core-security-server-internal/src/security_service.test.ts @@ -32,10 +32,8 @@ describe('SecurityService', function () { const mockConfig = { xpack: { security: { - experimental: { - fipsMode: { - enabled: !!getFips(), - }, + fipsMode: { + enabled: !!getFips(), }, }, }, diff --git a/packages/core/security/core-security-server-internal/src/utils/index.ts b/packages/core/security/core-security-server-internal/src/utils/index.ts index 666afcce38afd..ad4ed95e685ee 100644 --- a/packages/core/security/core-security-server-internal/src/utils/index.ts +++ b/packages/core/security/core-security-server-internal/src/utils/index.ts @@ -11,10 +11,8 @@ export { convertSecurityApi } from './convert_security_api'; export { getDefaultSecurityImplementation } from './default_implementation'; export interface SecurityServiceConfigType { - experimental?: { - fipsMode?: { - enabled: boolean; - }; + fipsMode?: { + enabled: boolean; }; } diff --git a/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts b/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts index 38dae90905cb2..0ec20dca7db8d 100644 --- a/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts +++ b/packages/core/test-helpers/core-test-helpers-kbn-server/src/create_root.ts @@ -83,7 +83,7 @@ export function createRootWithSettings( */ let oss = true; if (getFips() === 1) { - set(settings, 'xpack.security.experimental.fipsMode.enabled', true); + set(settings, 'xpack.security.fipsMode.enabled', true); oss = false; delete cliArgs.oss; } diff --git a/src/core/server/integration_tests/node/migrator.test.ts b/src/core/server/integration_tests/node/migrator.test.ts index f899d7da5cde0..c0ae1aab8ef29 100644 --- a/src/core/server/integration_tests/node/migrator.test.ts +++ b/src/core/server/integration_tests/node/migrator.test.ts @@ -44,7 +44,7 @@ describe('migrator-only node', () => { '--no-optimizer', '--no-base-path', '--no-watch', - isFipsEnabled ? '--xpack.security.experimental.fipsMode.enabled=true' : '--oss', + isFipsEnabled ? '--xpack.security.fipsMode.enabled=true' : '--oss', ], { stdio: ['pipe', 'pipe', 'pipe'] } ); diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index b814538466d73..e751997a1fb78 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -386,7 +386,7 @@ kibana_vars=( xpack.security.authc.selector.enabled xpack.security.cookieName xpack.security.encryptionKey - xpack.security.experimental.fipsMode.enabled + xpack.security.fipsMode.enabled xpack.security.loginAssistanceMessage xpack.security.loginHelp xpack.security.sameSiteCookies diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index ec5588b4c793e..94d604d726562 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -59,7 +59,7 @@ RUN set -e ; \ make install > /dev/null ; \ rm -rf "/usr/share/kibana/openssl-${OPENSSL_VERSION}" ; \ chown -R 1000:0 "${OPENSSL_PATH}"; - + {{/fips}} # Ensure that group permissions are the same as user permissions. # This will help when relying on GID-0 to run Kibana, rather than UID-1000. @@ -156,7 +156,7 @@ RUN /bin/echo -e '\n--enable-fips' >> config/node.options RUN echo '--openssl-config=/usr/share/kibana/config/nodejs.cnf' >> config/node.options COPY --chown=1000:0 openssl/nodejs.cnf "/usr/share/kibana/config/nodejs.cnf" ENV OPENSSL_MODULES=/usr/share/kibana/openssl/lib/ossl-modules -ENV XPACK_SECURITY_EXPERIMENTAL_FIPSMODE_ENABLED=true +ENV XPACK_SECURITY_FIPSMODE_ENABLED=true {{/fips}} RUN ln -s /usr/share/kibana /opt/kibana diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 2e2199ff850a1..38e37e290fa2b 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -62,10 +62,8 @@ describe('config schema', () => { }, "cookieName": "sid", "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "experimental": Object { - "fipsMode": Object { - "enabled": false, - }, + "fipsMode": Object { + "enabled": false, }, "loginAssistanceMessage": "", "public": Object {}, @@ -121,10 +119,8 @@ describe('config schema', () => { }, "cookieName": "sid", "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "experimental": Object { - "fipsMode": Object { - "enabled": false, - }, + "fipsMode": Object { + "enabled": false, }, "loginAssistanceMessage": "", "public": Object {}, @@ -179,10 +175,8 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", - "experimental": Object { - "fipsMode": Object { - "enabled": false, - }, + "fipsMode": Object { + "enabled": false, }, "loginAssistanceMessage": "", "public": Object {}, @@ -240,10 +234,8 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", - "experimental": Object { - "fipsMode": Object { - "enabled": false, - }, + "fipsMode": Object { + "enabled": false, }, "loginAssistanceMessage": "", "public": Object {}, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 8be1500bdccf1..f6af6188e6c76 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -315,10 +315,8 @@ export const ConfigSchema = schema.object({ roleMappingManagementEnabled: schema.boolean({ defaultValue: true }), }), }), - experimental: schema.object({ - fipsMode: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), + fipsMode: schema.object({ + enabled: schema.boolean({ defaultValue: false }), }), }); diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index 1245ef3978212..3be46e5ddeb79 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -46,6 +46,28 @@ describe('Config Deprecations', () => { expect(messages).toHaveLength(0); }); + it('renames `xpack.security.experimental.fipsMode.enabled` to `xpack.security.fipsMode.enabled`', () => { + const config = { + xpack: { + security: { + experimental: { + fipsMode: { + enabled: true, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.experimental?.fipsMode?.enabled).not.toBeDefined(); + expect(migrated.xpack.security.fipsMode.enabled).toEqual(true); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting \\"xpack.security.experimental.fipsMode.enabled\\" has been replaced by \\"xpack.security.fipsMode.enabled\\"", + ] + `); + }); + it('renames sessionTimeout to session.idleTimeout', () => { const config = { xpack: { diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts index 2e6a14b2028a2..2ee7d05c78b8e 100644 --- a/x-pack/plugins/security/server/config_deprecations.ts +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -21,6 +21,9 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ rename('audit.appender.policy.kind', 'audit.appender.policy.type', { level: 'warning' }), rename('audit.appender.strategy.kind', 'audit.appender.strategy.type', { level: 'warning' }), rename('audit.appender.path', 'audit.appender.fileName', { level: 'warning' }), + rename('experimental.fipsMode.enabled', 'fipsMode.enabled', { + level: 'critical', + }), renameFromRoot( 'security.showInsecureClusterWarning', diff --git a/x-pack/plugins/security/server/fips/fips_service.test.ts b/x-pack/plugins/security/server/fips/fips_service.test.ts index a3f74e058268a..6bdc0fea35acb 100644 --- a/x-pack/plugins/security/server/fips/fips_service.test.ts +++ b/x-pack/plugins/security/server/fips/fips_service.test.ts @@ -43,7 +43,7 @@ function buildMockFipsServiceSetupParams( let mockConfig = {}; if (isFipsConfigured) { - mockConfig = { experimental: { fipsMode: { enabled: true } } }; + mockConfig = { fipsMode: { enabled: true } }; } return { @@ -84,7 +84,7 @@ describe('FipsService', () => { describe('#validateLicenseForFips', () => { describe('start-up check', () => { - it('should not throw Error/log.error if license features allowFips and `experimental.fipsMode.enabled` is `false`', () => { + it('should not throw Error/log.error if license features allowFips and `fipsMode.enabled` is `false`', () => { fipsServiceSetup = fipsService.setup( buildMockFipsServiceSetupParams('platinum', false, of({ allowFips: true })) ); @@ -93,7 +93,7 @@ describe('FipsService', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should not throw Error/log.error if license features allowFips and `experimental.fipsMode.enabled` is `true`', () => { + it('should not throw Error/log.error if license features allowFips and `fipsMode.enabled` is `true`', () => { fipsServiceSetup = fipsService.setup( buildMockFipsServiceSetupParams('platinum', true, of({ allowFips: true })) ); @@ -102,7 +102,7 @@ describe('FipsService', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should not throw Error/log.error if license features do not allowFips and `experimental.fipsMode.enabled` is `false`', () => { + it('should not throw Error/log.error if license features do not allowFips and `fipsMode.enabled` is `false`', () => { fipsServiceSetup = fipsService.setup( buildMockFipsServiceSetupParams('basic', false, of({ allowFips: false })) ); @@ -111,7 +111,7 @@ describe('FipsService', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should throw Error/log.error if license features do not allowFips and `experimental.fipsMode.enabled` is `true`', () => { + it('should throw Error/log.error if license features do not allowFips and `fipsMode.enabled` is `true`', () => { fipsServiceSetup = fipsService.setup( buildMockFipsServiceSetupParams('basic', true, of({ allowFips: false })) ); @@ -124,7 +124,7 @@ describe('FipsService', () => { }); describe('monitoring check', () => { - describe('with experimental.fipsMode.enabled', () => { + describe('with fipsMode.enabled', () => { let mockFeaturesSubject: BehaviorSubject>; let mockIsAvailableSubject: BehaviorSubject; let mockFeatures$: Observable>; @@ -149,23 +149,23 @@ describe('FipsService', () => { mockIsAvailableSubject.next(true); }); - it('should not log.error if license changes to unavailable and `experimental.fipsMode.enabled` is `true`', () => { + it('should not log.error if license changes to unavailable and `fipsMode.enabled` is `true`', () => { mockIsAvailableSubject.next(false); expect(logger.error).not.toHaveBeenCalled(); }); - it('should not log.error if license features continue to allowFips and `experimental.fipsMode.enabled` is `true`', () => { + it('should not log.error if license features continue to allowFips and `fipsMode.enabled` is `true`', () => { mockFeaturesSubject.next({ allowFips: true }); expect(logger.error).not.toHaveBeenCalled(); }); - it('should log.error if license features change to not allowFips and `experimental.fipsMode.enabled` is `true`', () => { + it('should log.error if license features change to not allowFips and `fipsMode.enabled` is `true`', () => { mockFeaturesSubject.next({ allowFips: false }); expect(logger.error).toHaveBeenCalledTimes(1); }); }); - describe('with not experimental.fipsMode.enabled', () => { + describe('with not fipsMode.enabled', () => { let mockFeaturesSubject: BehaviorSubject>; let mockIsAvailableSubject: BehaviorSubject; let mockFeatures$: Observable>; @@ -191,17 +191,17 @@ describe('FipsService', () => { mockIsAvailableSubject.next(true); }); - it('should not log.error if license changes to unavailable and `experimental.fipsMode.enabled` is `false`', () => { + it('should not log.error if license changes to unavailable and `fipsMode.enabled` is `false`', () => { mockIsAvailableSubject.next(false); expect(logger.error).not.toHaveBeenCalled(); }); - it('should not log.error if license features continue to allowFips and `experimental.fipsMode.enabled` is `false`', () => { + it('should not log.error if license features continue to allowFips and `fipsMode.enabled` is `false`', () => { mockFeaturesSubject.next({ allowFips: true }); expect(logger.error).not.toHaveBeenCalled(); }); - it('should not log.error if license change to not allowFips and `experimental.fipsMode.enabled` is `false`', () => { + it('should not log.error if license change to not allowFips and `fipsMode.enabled` is `false`', () => { mockFeaturesSubject.next({ allowFips: false }); expect(logger.error).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security/server/fips/fips_service.ts b/x-pack/plugins/security/server/fips/fips_service.ts index aa351ab48828d..9f9c01254bca2 100644 --- a/x-pack/plugins/security/server/fips/fips_service.ts +++ b/x-pack/plugins/security/server/fips/fips_service.ts @@ -40,7 +40,7 @@ export class FipsService { const errorMessage = `Your current license level is ${license.getLicenseType()} and does not support running in FIPS mode.`; if (license.isLicenseAvailable() && !this.isInitialLicenseLoaded) { - if (config?.experimental.fipsMode.enabled && !license.getFeatures().allowFips) { + if (config?.fipsMode.enabled && !license.getFeatures().allowFips) { this.logger.error(errorMessage); throw new Error(errorMessage); } @@ -51,7 +51,7 @@ export class FipsService { if ( this.isInitialLicenseLoaded && license.isLicenseAvailable() && - config?.experimental.fipsMode.enabled && + config?.fipsMode.enabled && !features.allowFips ) { this.logger.error( From 755ef312f2d533117ce3f614f8586e9c4db657be Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Tue, 19 Nov 2024 13:45:31 -0700 Subject: [PATCH 53/61] [Security solution] `ChatBedrockConverse` (#200042) --- package.json | 8 +- x-pack/packages/kbn-langchain/server/index.ts | 2 + .../server/language_models/bedrock_chat.ts | 58 +- .../bedrock_runtime_client.ts | 37 + .../chat_bedrock_converse.ts | 50 + .../chat_bedrock_converse/index.ts | 10 + .../node_http_handler.test.ts | 125 ++ .../node_http_handler.ts | 88 ++ .../kbn-langchain/server/utils/bedrock.ts | 24 + .../connector_types.test.ts.snap | 1108 ++++++++++++++++- .../nodes/translations.ts | 2 +- .../elastic_assistant/server/routes/utils.ts | 4 +- .../plugins/elastic_assistant/server/types.ts | 4 +- .../rules/task/util/actions_client_chat.ts | 7 +- .../common/bedrock/constants.ts | 2 + .../stack_connectors/common/bedrock/schema.ts | 56 + .../stack_connectors/common/bedrock/types.ts | 4 + .../server/connector_types/bedrock/bedrock.ts | 81 +- yarn.lock | 968 +++++++++++++- 19 files changed, 2482 insertions(+), 156 deletions(-) create mode 100644 x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/bedrock_runtime_client.ts create mode 100644 x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/chat_bedrock_converse.ts create mode 100644 x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/index.ts create mode 100644 x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.test.ts create mode 100644 x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.ts diff --git a/package.json b/package.json index eed5b3a9b61cb..ea8bd1feda2f9 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "@appland/sql-parser": "^1.5.1", "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/util": "^5.2.0", + "@aws-sdk/client-bedrock-runtime": "^3.687.0", "@babel/runtime": "^7.24.7", "@dagrejs/dagre": "^1.1.4", "@dnd-kit/core": "^6.1.0", @@ -1019,7 +1020,8 @@ "@kbn/xstate-utils": "link:packages/kbn-xstate-utils", "@kbn/zod": "link:packages/kbn-zod", "@kbn/zod-helpers": "link:packages/kbn-zod-helpers", - "@langchain/community": "0.3.11", + "@langchain/aws": "^0.1.2", + "@langchain/community": "0.3.14", "@langchain/core": "^0.3.16", "@langchain/google-common": "^0.1.1", "@langchain/google-genai": "^0.1.2", @@ -1054,7 +1056,9 @@ "@slack/webhook": "^7.0.1", "@smithy/eventstream-codec": "^3.1.1", "@smithy/eventstream-serde-node": "^3.0.3", - "@smithy/protocol-http": "^4.0.2", + "@smithy/middleware-stack": "^3.0.10", + "@smithy/node-http-handler": "^3.3.1", + "@smithy/protocol-http": "^4.1.7", "@smithy/signature-v4": "^3.1.1", "@smithy/types": "^3.2.0", "@smithy/util-utf8": "^3.0.0", diff --git a/x-pack/packages/kbn-langchain/server/index.ts b/x-pack/packages/kbn-langchain/server/index.ts index 4ffe3aec864d6..ebd1c0e5b49d4 100644 --- a/x-pack/packages/kbn-langchain/server/index.ts +++ b/x-pack/packages/kbn-langchain/server/index.ts @@ -11,6 +11,7 @@ import { ActionsClientLlm } from './language_models/llm'; import { ActionsClientSimpleChatModel } from './language_models/simple_chat_model'; import { ActionsClientGeminiChatModel } from './language_models/gemini_chat'; import { ActionsClientChatVertexAI } from './language_models/chat_vertex'; +import { ActionsClientChatBedrockConverse } from './language_models/chat_bedrock_converse'; import { parseBedrockStream } from './utils/bedrock'; import { parseGeminiResponse } from './utils/gemini'; import { getDefaultArguments } from './language_models/constants'; @@ -25,4 +26,5 @@ export { ActionsClientGeminiChatModel, ActionsClientLlm, ActionsClientSimpleChatModel, + ActionsClientChatBedrockConverse, }; diff --git a/x-pack/packages/kbn-langchain/server/language_models/bedrock_chat.ts b/x-pack/packages/kbn-langchain/server/language_models/bedrock_chat.ts index ac229b97c8757..70395298d3c98 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/bedrock_chat.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/bedrock_chat.ts @@ -9,11 +9,8 @@ import { BedrockChat as _BedrockChat } from '@langchain/community/chat_models/be import type { ActionsClient } from '@kbn/actions-plugin/server'; import { BaseChatModelParams } from '@langchain/core/language_models/chat_models'; import { Logger } from '@kbn/logging'; -import { Readable } from 'stream'; import { PublicMethodsOf } from '@kbn/utility-types'; - -export const DEFAULT_BEDROCK_MODEL = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; -export const DEFAULT_BEDROCK_REGION = 'us-east-1'; +import { prepareMessages, DEFAULT_BEDROCK_MODEL, DEFAULT_BEDROCK_REGION } from '../utils/bedrock'; export interface CustomChatModelInput extends BaseChatModelParams { actionsClient: PublicMethodsOf; @@ -25,6 +22,11 @@ export interface CustomChatModelInput extends BaseChatModelParams { maxTokens?: number; } +/** + * @deprecated Use the ActionsClientChatBedrockConverse chat model instead. + * ActionsClientBedrockChatModel chat model supports non-streaming only the Bedrock Invoke API. + * The LangChain team will support only the Bedrock Converse API in the future. + */ export class ActionsClientBedrockChatModel extends _BedrockChat { constructor({ actionsClient, connectorId, logger, ...params }: CustomChatModelInput) { super({ @@ -36,32 +38,10 @@ export class ActionsClientBedrockChatModel extends _BedrockChat { fetchFn: async (url, options) => { const inputBody = JSON.parse(options?.body as string); - if (this.streaming && !inputBody.tools?.length) { - const data = (await actionsClient.execute({ - actionId: connectorId, - params: { - subAction: 'invokeStream', - subActionParams: { - messages: inputBody.messages, - temperature: params.temperature ?? inputBody.temperature, - stopSequences: inputBody.stop_sequences, - system: inputBody.system, - maxTokens: params.maxTokens ?? inputBody.max_tokens, - tools: inputBody.tools, - anthropicVersion: inputBody.anthropic_version, - }, - }, - })) as { data: Readable; status: string; message?: string; serviceMessage?: string }; - - if (data.status === 'error') { - throw new Error( - `ActionsClientBedrockChat: action result status is error: ${data?.message} - ${data?.serviceMessage}` - ); - } - - return { - body: Readable.toWeb(data.data), - } as unknown as Response; + if (this.streaming) { + throw new Error( + `ActionsClientBedrockChat does not support streaming, use ActionsClientChatBedrockConverse instead` + ); } const data = (await actionsClient.execute({ @@ -84,7 +64,6 @@ export class ActionsClientBedrockChatModel extends _BedrockChat { message?: string; serviceMessage?: string; }; - if (data.status === 'error') { throw new Error( `ActionsClientBedrockChat: action result status is error: ${data?.message} - ${data?.serviceMessage}` @@ -99,20 +78,3 @@ export class ActionsClientBedrockChatModel extends _BedrockChat { }); } } - -const prepareMessages = (messages: Array<{ role: string; content: string[] }>) => - messages.reduce((acc, { role, content }) => { - const lastMessage = acc[acc.length - 1]; - - if (!lastMessage || lastMessage.role !== role) { - acc.push({ role, content }); - return acc; - } - - if (lastMessage.role === role) { - acc[acc.length - 1].content = lastMessage.content.concat(content); - return acc; - } - - return acc; - }, [] as Array<{ role: string; content: string[] }>); diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/bedrock_runtime_client.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/bedrock_runtime_client.ts new file mode 100644 index 0000000000000..359342870a8b9 --- /dev/null +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/bedrock_runtime_client.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 { + BedrockRuntimeClient as _BedrockRuntimeClient, + BedrockRuntimeClientConfig, +} from '@aws-sdk/client-bedrock-runtime'; +import { constructStack } from '@smithy/middleware-stack'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; + +import { NodeHttpHandler } from './node_http_handler'; + +export interface CustomChatModelInput extends BedrockRuntimeClientConfig { + actionsClient: PublicMethodsOf; + connectorId: string; + streaming?: boolean; +} + +export class BedrockRuntimeClient extends _BedrockRuntimeClient { + middlewareStack: _BedrockRuntimeClient['middlewareStack']; + + constructor({ actionsClient, connectorId, ...fields }: CustomChatModelInput) { + super(fields ?? {}); + this.config.requestHandler = new NodeHttpHandler({ + streaming: fields.streaming ?? true, + actionsClient, + connectorId, + }); + // eliminate middleware steps that handle auth as Kibana connector handles auth + this.middlewareStack = constructStack() as _BedrockRuntimeClient['middlewareStack']; + } +} diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/chat_bedrock_converse.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/chat_bedrock_converse.ts new file mode 100644 index 0000000000000..bdc84130925d6 --- /dev/null +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/chat_bedrock_converse.ts @@ -0,0 +1,50 @@ +/* + * 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 { ChatBedrockConverse } from '@langchain/aws'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import { BaseChatModelParams } from '@langchain/core/language_models/chat_models'; +import { Logger } from '@kbn/logging'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { BedrockRuntimeClient } from './bedrock_runtime_client'; +import { DEFAULT_BEDROCK_MODEL, DEFAULT_BEDROCK_REGION } from '../../utils/bedrock'; + +export interface CustomChatModelInput extends BaseChatModelParams { + actionsClient: PublicMethodsOf; + connectorId: string; + logger: Logger; + signal?: AbortSignal; + model?: string; +} + +/** + * Custom chat model class for Bedrock Converse API. + * The ActionsClientChatBedrockConverse chat model supports streaming and + * non-streaming via the Bedrock Converse and ConverseStream APIs. + * + * @param {Object} params - The parameters for the chat model. + * @param {ActionsClient} params.actionsClient - The actions client. + * @param {string} params.connectorId - The connector ID. + * @param {Logger} params.logger - The logger instance. + * @param {AbortSignal} [params.signal] - Optional abort signal. + * @param {string} [params.model] - Optional model name. + */ +export class ActionsClientChatBedrockConverse extends ChatBedrockConverse { + constructor({ actionsClient, connectorId, logger, ...fields }: CustomChatModelInput) { + super({ + ...(fields ?? {}), + credentials: { accessKeyId: '', secretAccessKey: '' }, + model: fields?.model ?? DEFAULT_BEDROCK_MODEL, + region: DEFAULT_BEDROCK_REGION, + }); + this.client = new BedrockRuntimeClient({ + actionsClient, + connectorId, + streaming: this.streaming, + region: DEFAULT_BEDROCK_REGION, + }); + } +} diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/index.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/index.ts new file mode 100644 index 0000000000000..2d22184224166 --- /dev/null +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/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. + */ + +import { ActionsClientChatBedrockConverse } from './chat_bedrock_converse'; + +export { ActionsClientChatBedrockConverse }; diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.test.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.test.ts new file mode 100644 index 0000000000000..ba8a1db1fbb00 --- /dev/null +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { NodeHttpHandler } from './node_http_handler'; +import { HttpRequest } from '@smithy/protocol-http'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; +import { Readable } from 'stream'; +import { fromUtf8 } from '@smithy/util-utf8'; + +const mockActionsClient = actionsClientMock.create(); +const connectorId = 'mock-connector-id'; +const mockOutput = { + output: { + message: { + role: 'assistant', + content: [{ text: 'This is a response from the assistant.' }], + }, + }, + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + metrics: { latencyMs: 123 }, + additionalModelResponseFields: {}, + trace: { guardrail: { modelOutput: ['Output text'] } }, +}; +describe('NodeHttpHandler', () => { + let handler: NodeHttpHandler; + + beforeEach(() => { + jest.clearAllMocks(); + handler = new NodeHttpHandler({ + streaming: false, + actionsClient: mockActionsClient, + connectorId, + }); + + mockActionsClient.execute.mockResolvedValue({ + data: mockOutput, + actionId: 'mock-action-id', + status: 'ok', + }); + }); + + it('handles non-streaming requests successfully', async () => { + const request = new HttpRequest({ + body: JSON.stringify({ messages: [] }), + }); + + const result = await handler.handle(request); + + expect(result.response.statusCode).toBe(200); + expect(result.response.headers['content-type']).toBe('application/json'); + expect(result.response.body).toStrictEqual(fromUtf8(JSON.stringify(mockOutput))); + }); + + it('handles streaming requests successfully', async () => { + handler = new NodeHttpHandler({ + streaming: true, + actionsClient: mockActionsClient, + connectorId, + }); + + const request = new HttpRequest({ + body: JSON.stringify({ messages: [] }), + }); + + const readable = new Readable(); + readable.push('streaming data'); + readable.push(null); + + mockActionsClient.execute.mockResolvedValue({ + data: readable, + status: 'ok', + actionId: 'mock-action-id', + }); + + const result = await handler.handle(request); + + expect(result.response.statusCode).toBe(200); + expect(result.response.body).toBe(readable); + }); + + it('throws an error for non-streaming requests with error status', async () => { + const request = new HttpRequest({ + body: JSON.stringify({ messages: [] }), + }); + + mockActionsClient.execute.mockResolvedValue({ + status: 'error', + message: 'error message', + serviceMessage: 'service error message', + actionId: 'mock-action-id', + }); + + await expect(handler.handle(request)).rejects.toThrow( + 'ActionsClientBedrockChat: action result status is error: error message - service error message' + ); + }); + + it('throws an error for streaming requests with error status', async () => { + handler = new NodeHttpHandler({ + streaming: true, + actionsClient: mockActionsClient, + connectorId, + }); + + const request = new HttpRequest({ + body: JSON.stringify({ messages: [] }), + }); + + mockActionsClient.execute.mockResolvedValue({ + status: 'error', + message: 'error message', + serviceMessage: 'service error message', + actionId: 'mock-action-id', + }); + + await expect(handler.handle(request)).rejects.toThrow( + 'ActionsClientBedrockChat: action result status is error: error message - service error message' + ); + }); +}); diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.ts new file mode 100644 index 0000000000000..bd5143ef45d4a --- /dev/null +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_bedrock_converse/node_http_handler.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 { NodeHttpHandler as _NodeHttpHandler } from '@smithy/node-http-handler'; +import { HttpRequest, HttpResponse } from '@smithy/protocol-http'; +import { HttpHandlerOptions, NodeHttpHandlerOptions } from '@smithy/types'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import { Readable } from 'stream'; +import { fromUtf8 } from '@smithy/util-utf8'; +import { ConverseResponse } from '@aws-sdk/client-bedrock-runtime'; +import { prepareMessages } from '../../utils/bedrock'; + +interface NodeHandlerOptions extends NodeHttpHandlerOptions { + streaming: boolean; + actionsClient: PublicMethodsOf; + connectorId: string; +} + +export class NodeHttpHandler extends _NodeHttpHandler { + streaming: boolean; + actionsClient: PublicMethodsOf; + connectorId: string; + constructor(options: NodeHandlerOptions) { + super(options); + this.streaming = options.streaming; + this.actionsClient = options.actionsClient; + this.connectorId = options.connectorId; + } + + async handle( + request: HttpRequest, + options: HttpHandlerOptions = {} + ): Promise<{ response: HttpResponse }> { + const body = JSON.parse(request.body); + const messages = prepareMessages(body.messages); + + if (this.streaming) { + const data = (await this.actionsClient.execute({ + actionId: this.connectorId, + params: { + subAction: 'converseStream', + subActionParams: { ...body, messages, signal: options.abortSignal }, + }, + })) as { data: Readable; status: string; message?: string; serviceMessage?: string }; + + if (data.status === 'error') { + throw new Error( + `ActionsClientBedrockChat: action result status is error: ${data?.message} - ${data?.serviceMessage}` + ); + } + + return { + response: { + statusCode: 200, + headers: {}, + body: data.data, + }, + }; + } + + const data = (await this.actionsClient.execute({ + actionId: this.connectorId, + params: { + subAction: 'converse', + subActionParams: { ...body, messages, signal: options.abortSignal }, + }, + })) as { data: ConverseResponse; status: string; message?: string; serviceMessage?: string }; + + if (data.status === 'error') { + throw new Error( + `ActionsClientBedrockChat: action result status is error: ${data?.message} - ${data?.serviceMessage}` + ); + } + + return { + response: { + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: fromUtf8(JSON.stringify(data.data)), + }, + }; + } +} diff --git a/x-pack/packages/kbn-langchain/server/utils/bedrock.ts b/x-pack/packages/kbn-langchain/server/utils/bedrock.ts index 39e5e77864fef..7c8c069e5eb5a 100644 --- a/x-pack/packages/kbn-langchain/server/utils/bedrock.ts +++ b/x-pack/packages/kbn-langchain/server/utils/bedrock.ts @@ -222,3 +222,27 @@ function parseContent(content: Array<{ text?: string; type: string }>): string { } return parsedContent; } + +/** + * Prepare messages for the bedrock API by combining messages from the same role + * @param messages + */ +export const prepareMessages = (messages: Array<{ role: string; content: string[] }>) => + messages.reduce((acc, { role, content }) => { + const lastMessage = acc[acc.length - 1]; + + if (!lastMessage || lastMessage.role !== role) { + acc.push({ role, content }); + return acc; + } + + if (lastMessage.role === role) { + acc[acc.length - 1].content = lastMessage.content.concat(content); + return acc; + } + + return acc; + }, [] as Array<{ role: string; content: string[] }>); + +export const DEFAULT_BEDROCK_MODEL = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; +export const DEFAULT_BEDROCK_REGION = 'us-east-1'; diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index e8b3cc7bebe2a..86f9124702fb2 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -10,6 +10,45 @@ Object { "presence": "optional", }, "keys": Object { + "apiType": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "converse", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "invoke", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "alternatives", + }, "body": Object { "flags": Object { "error": [Function], @@ -131,6 +170,45 @@ Object { "presence": "optional", }, "keys": Object { + "apiType": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "converse", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "invoke", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "alternatives", + }, "body": Object { "flags": Object { "error": [Function], @@ -1393,85 +1471,1019 @@ Object { "presence": "optional", }, "keys": Object { - "apiUrl": Object { + "additionalModelRequestFields": Object { "flags": Object { + "default": [Function], "error": [Function], + "presence": "optional", }, - "rules": Array [ + "metas": Array [ Object { - "args": Object { - "method": [Function], - }, - "name": "custom", + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, }, ], - "type": "string", + "type": "any", }, - "defaultModel": Object { + "additionalModelResponseFieldPaths": Object { "flags": Object { - "default": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "default": [Function], "error": [Function], "presence": "optional", }, - "rules": Array [ + "metas": Array [ Object { - "args": Object { - "method": [Function], - }, - "name": "custom", + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, }, ], - "type": "string", - }, - }, - "type": "object", -} -`; - -exports[`Connector type config checks detect connector type changes for: .bedrock 8`] = ` -Object { - "flags": Object { - "default": Object { - "special": "deep", + "type": "any", }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "accessKey": Object { + "guardrailConfig": Object { "flags": Object { + "default": [Function], "error": [Function], + "presence": "optional", }, - "rules": Array [ + "metas": Array [ Object { - "args": Object { - "method": [Function], - }, - "name": "custom", + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, }, ], - "type": "string", + "type": "any", }, - "secret": Object { + "inferenceConfig": Object { "flags": Object { + "default": Object { + "special": "deep", + }, "error": [Function], + "presence": "optional", }, - "rules": Array [ + "keys": Object { + "maxTokens": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "number", + }, + "stopSequences": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "array", + }, + "temperature": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "number", + }, + "topP": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "number", + }, + }, + "type": "object", + }, + "messages": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ Object { - "args": Object { - "method": [Function], + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", }, - "name": "custom", + "keys": Object { + "content": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + ], + "type": "any", + }, + "role": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", }, ], - "type": "string", + "type": "array", }, - }, - "type": "object", -} -`; - + "modelId": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "signal": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, + }, + ], + "type": "any", + }, + "system": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "text": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + "toolConfig": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "toolChoice": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + "tools": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "toolSpec": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "description": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "inputSchema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "json": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "$schema": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "additionalProperties": Object { + "flags": Object { + "error": [Function], + }, + "type": "boolean", + }, + "properties": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + "required": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "array", + }, + "type": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "object", + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .bedrock 8`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "additionalModelRequestFields": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, + }, + ], + "type": "any", + }, + "additionalModelResponseFieldPaths": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, + }, + ], + "type": "any", + }, + "guardrailConfig": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, + }, + ], + "type": "any", + }, + "inferenceConfig": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "maxTokens": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "number", + }, + "stopSequences": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "array", + }, + "temperature": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "number", + }, + "topP": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "number", + }, + }, + "type": "object", + }, + "messages": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "content": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + ], + "type": "any", + }, + "role": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + "modelId": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "signal": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + Object { + "x-oas-optional": true, + }, + ], + "type": "any", + }, + "system": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "text": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + "toolConfig": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "toolChoice": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + "tools": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "toolSpec": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "description": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "inputSchema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "json": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "$schema": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "additionalProperties": Object { + "flags": Object { + "error": [Function], + }, + "type": "boolean", + }, + "properties": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + "required": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "array", + }, + "type": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + "metas": Array [ + Object { + "x-oas-optional": true, + }, + ], + "type": "object", + }, + }, + "type": "object", +} +`; + exports[`Connector type config checks detect connector type changes for: .bedrock 9`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "defaultModel": Object { + "flags": Object { + "default": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .bedrock 10`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "accessKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "secret": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .bedrock 11`] = ` Object { "flags": Object { "default": Object { diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts index e5a1c14846e23..aee78c16920d8 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts @@ -18,7 +18,7 @@ const BASE_GEMINI_PROMPT = const KB_CATCH = 'If the knowledge base tool gives empty results, do your best to answer the question from the perspective of an expert security analyst.'; export const GEMINI_SYSTEM_PROMPT = `${BASE_GEMINI_PROMPT} ${KB_CATCH}`; -export const BEDROCK_SYSTEM_PROMPT = `Use tools as often as possible, as they have access to the latest data and syntax. Always return value from ESQLKnowledgeBaseTool as is. Never return tags in the response, but make sure to include tags content in the response. Do not reflect on the quality of the returned search results in your response.`; +export const BEDROCK_SYSTEM_PROMPT = `Use tools as often as possible, as they have access to the latest data and syntax. Always return value from NaturalLanguageESQLTool as is. Never return tags in the response, but make sure to include tags content in the response. Do not reflect on the quality of the returned search results in your response.`; export const GEMINI_USER_PROMPT = `Now, always using the tools at your disposal, step by step, come up with a response to this request:\n\n`; export const STRUCTURED_SYSTEM_PROMPT = `Respond to the human as helpfully and accurately as possible. ${KNOWLEDGE_HISTORY} You have access to the following tools: diff --git a/x-pack/plugins/elastic_assistant/server/routes/utils.ts b/x-pack/plugins/elastic_assistant/server/routes/utils.ts index 54f9ef2c04b90..4cc213f0e0db8 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/utils.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/utils.ts @@ -15,7 +15,7 @@ import type { } from '@kbn/core/server'; import { ActionsClientChatOpenAI, - ActionsClientBedrockChatModel, + ActionsClientChatBedrockConverse, ActionsClientChatVertexAI, } from '@kbn/langchain/server'; import { Connector } from '@kbn/actions-plugin/server/application/connector/types'; @@ -184,7 +184,7 @@ export const getLlmType = (actionTypeId: string): string | undefined => { export const getLlmClass = (llmType?: string) => { switch (llmType) { case 'bedrock': - return ActionsClientBedrockChatModel; + return ActionsClientChatBedrockConverse; case 'gemini': return ActionsClientChatVertexAI; case 'openai': diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index d328001e86bb8..93f35d11eb877 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -38,7 +38,7 @@ import { LicensingPluginStart, } from '@kbn/licensing-plugin/server'; import { - ActionsClientBedrockChatModel, + ActionsClientChatBedrockConverse, ActionsClientChatOpenAI, ActionsClientChatVertexAI, ActionsClientGeminiChatModel, @@ -215,7 +215,7 @@ export interface AssistantTool { } export type AssistantToolLlm = - | ActionsClientBedrockChatModel + | ActionsClientChatBedrockConverse | ActionsClientChatOpenAI | ActionsClientGeminiChatModel | ActionsClientChatVertexAI; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts index 204978c901df6..1659862543078 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts @@ -33,10 +33,7 @@ export type ActionsClientChatModelClass = export type ChatModelParams = Partial & Partial & Partial & - Partial & { - /** Enables the streaming mode of the response, disabled by default */ - streaming?: boolean; - }; + Partial; const llmTypeDictionary: Record = { [`.gen-ai`]: `openai`, @@ -67,7 +64,7 @@ export class ActionsClientChat { llmType, model: connector.config?.defaultModel, ...params, - streaming: params?.streaming ?? false, // disabling streaming by default, for some reason is enabled when omitted + streaming: false, // disabling streaming by default }); return model; } diff --git a/x-pack/plugins/stack_connectors/common/bedrock/constants.ts b/x-pack/plugins/stack_connectors/common/bedrock/constants.ts index f3b133dd783f6..d2ffa0b116bda 100644 --- a/x-pack/plugins/stack_connectors/common/bedrock/constants.ts +++ b/x-pack/plugins/stack_connectors/common/bedrock/constants.ts @@ -21,6 +21,8 @@ export enum SUB_ACTION { INVOKE_STREAM = 'invokeStream', DASHBOARD = 'getDashboard', TEST = 'test', + CONVERSE = 'converse', + CONVERSE_STREAM = 'converseStream', } export const DEFAULT_TIMEOUT_MS = 120000; diff --git a/x-pack/plugins/stack_connectors/common/bedrock/schema.ts b/x-pack/plugins/stack_connectors/common/bedrock/schema.ts index 15ac45c0cf597..c444159c010b2 100644 --- a/x-pack/plugins/stack_connectors/common/bedrock/schema.ts +++ b/x-pack/plugins/stack_connectors/common/bedrock/schema.ts @@ -26,6 +26,11 @@ export const RunActionParamsSchema = schema.object({ signal: schema.maybe(schema.any()), timeout: schema.maybe(schema.number()), raw: schema.maybe(schema.boolean()), + apiType: schema.maybe( + schema.oneOf([schema.literal('converse'), schema.literal('invoke')], { + defaultValue: 'invoke', + }) + ), }); export const BedrockMessageSchema = schema.object( @@ -148,3 +153,54 @@ export const DashboardActionParamsSchema = schema.object({ export const DashboardActionResponseSchema = schema.object({ available: schema.boolean(), }); + +export const ConverseActionParamsSchema = schema.object({ + // Bedrock API Properties + modelId: schema.maybe(schema.string()), + messages: schema.arrayOf( + schema.object({ + role: schema.string(), + content: schema.any(), + }) + ), + system: schema.arrayOf( + schema.object({ + text: schema.string(), + }) + ), + inferenceConfig: schema.object({ + temperature: schema.maybe(schema.number()), + maxTokens: schema.maybe(schema.number()), + stopSequences: schema.maybe(schema.arrayOf(schema.string())), + topP: schema.maybe(schema.number()), + }), + toolConfig: schema.maybe( + schema.object({ + tools: schema.arrayOf( + schema.object({ + toolSpec: schema.object({ + name: schema.string(), + description: schema.string(), + inputSchema: schema.object({ + json: schema.object({ + type: schema.string(), + properties: schema.object({}, { unknowns: 'allow' }), + required: schema.maybe(schema.arrayOf(schema.string())), + additionalProperties: schema.boolean(), + $schema: schema.maybe(schema.string()), + }), + }), + }), + }) + ), + toolChoice: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }) + ), + additionalModelRequestFields: schema.maybe(schema.any()), + additionalModelResponseFieldPaths: schema.maybe(schema.any()), + guardrailConfig: schema.maybe(schema.any()), + // Kibana related properties + signal: schema.maybe(schema.any()), +}); + +export const ConverseActionResponseSchema = schema.object({}, { unknowns: 'allow' }); diff --git a/x-pack/plugins/stack_connectors/common/bedrock/types.ts b/x-pack/plugins/stack_connectors/common/bedrock/types.ts index 9d742e5f892a8..e3dd49538176f 100644 --- a/x-pack/plugins/stack_connectors/common/bedrock/types.ts +++ b/x-pack/plugins/stack_connectors/common/bedrock/types.ts @@ -21,6 +21,8 @@ import { RunApiLatestResponseSchema, BedrockMessageSchema, BedrockToolChoiceSchema, + ConverseActionParamsSchema, + ConverseActionResponseSchema, } from './schema'; export type Config = TypeOf; @@ -37,3 +39,5 @@ export type DashboardActionParams = TypeOf; export type DashboardActionResponse = TypeOf; export type BedrockMessage = TypeOf; export type BedrockToolChoice = TypeOf; +export type ConverseActionParams = TypeOf; +export type ConverseActionResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts index 9bd5c64404f64..55b631ba9441c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts @@ -21,8 +21,9 @@ import { StreamingResponseSchema, RunActionResponseSchema, RunApiLatestResponseSchema, + ConverseActionParamsSchema, } from '../../../common/bedrock/schema'; -import type { +import { Config, Secrets, RunActionParams, @@ -34,6 +35,8 @@ import type { RunApiLatestResponse, BedrockMessage, BedrockToolChoice, + ConverseActionParams, + ConverseActionResponse, } from '../../../common/bedrock/types'; import { SUB_ACTION, @@ -103,6 +106,18 @@ export class BedrockConnector extends SubActionConnector { method: 'invokeAIRaw', schema: InvokeAIRawActionParamsSchema, }); + + this.registerSubAction({ + name: SUB_ACTION.CONVERSE, + method: 'converse', + schema: ConverseActionParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.CONVERSE_STREAM, + method: 'converseStream', + schema: ConverseActionParamsSchema, + }); } protected getResponseErrorMessage(error: AxiosError<{ message?: string }>): string { @@ -222,14 +237,18 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B * responsible for making a POST request to the external API endpoint and returning the response data * @param body The stringified request body to be sent in the POST request. * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. + * @param signal Optional signal to cancel the request. + * @param timeout Optional timeout for the request. + * @param raw Optional flag to indicate if the response should be returned as raw data. + * @param apiType Optional type of API to be called. Defaults to 'invoke', . */ public async runApi( - { body, model: reqModel, signal, timeout, raw }: RunActionParams, + { body, model: reqModel, signal, timeout, raw, apiType = 'invoke' }: RunActionParams, connectorUsageCollector: ConnectorUsageCollector ): Promise { // set model on per request basis const currentModel = reqModel ?? this.model; - const path = `/model/${currentModel}/invoke`; + const path = `/model/${currentModel}/${apiType}`; const signed = this.signRequest(body, path, false); const requestArgs = { ...signed, @@ -262,18 +281,22 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B /** * NOT INTENDED TO BE CALLED DIRECTLY - * call invokeStream instead + * call invokeStream or converseStream instead * responsible for making a POST request to a specified URL with a given request body. * The response is then processed based on whether it is a streaming response or a regular response. * @param body The stringified request body to be sent in the POST request. * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. */ private async streamApi( - { body, model: reqModel, signal, timeout }: RunActionParams, + { body, model: reqModel, signal, timeout, apiType = 'invoke' }: RunActionParams, connectorUsageCollector: ConnectorUsageCollector ): Promise { + const streamingApiRoute = { + invoke: 'invoke-with-response-stream', + converse: 'converse-stream', + }; // set model on per request basis - const path = `/model/${reqModel ?? this.model}/invoke-with-response-stream`; + const path = `/model/${reqModel ?? this.model}/${streamingApiRoute[apiType]}`; const signed = this.signRequest(body, path, true); const response = await this.request( @@ -312,7 +335,7 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B timeout, tools, toolChoice, - }: InvokeAIActionParams | InvokeAIRawActionParams, + }: InvokeAIRawActionParams, connectorUsageCollector: ConnectorUsageCollector ): Promise { const res = (await this.streamApi( @@ -411,6 +434,50 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B ); return res; } + + /** + * Sends a request to the Bedrock API to perform a conversation action. + * @param input - The parameters for the conversation action. + * @param connectorUsageCollector - The usage collector for the connector. + * @returns A promise that resolves to the response of the conversation action. + */ + public async converse( + { signal, ...converseApiInput }: ConverseActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.runApi( + { + body: JSON.stringify(converseApiInput), + raw: true, + apiType: 'converse', + signal, + }, + connectorUsageCollector + ); + return res; + } + + /** + * Sends a request to the Bedrock API to perform a streaming conversation action. + * @param input - The parameters for the streaming conversation action. + * @param connectorUsageCollector - The usage collector for the connector. + * @returns A promise that resolves to the streaming response of the conversation action. + */ + public async converseStream( + { signal, ...converseApiInput }: ConverseActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.streamApi( + { + body: JSON.stringify(converseApiInput), + apiType: 'converse', + signal, + }, + connectorUsageCollector + ); + + return res; + } } const formatBedrockBody = ({ diff --git a/yarn.lock b/yarn.lock index 5b3b6f246ff42..bd0a376098b09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -86,7 +86,20 @@ "@aws-sdk/types" "^3.222.0" tslib "^2.6.2" -"@aws-crypto/sha256-js@^5.2.0": +"@aws-crypto/sha256-browser@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz#153895ef1dba6f9fce38af550e0ef58988eb649e" + integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== + dependencies: + "@aws-crypto/sha256-js" "^5.2.0" + "@aws-crypto/supports-web-crypto" "^5.2.0" + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + "@aws-sdk/util-locate-window" "^3.0.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz#c4fdb773fdbed9a664fc1a95724e206cf3860042" integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== @@ -95,6 +108,13 @@ "@aws-sdk/types" "^3.222.0" tslib "^2.6.2" +"@aws-crypto/supports-web-crypto@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz#a1e399af29269be08e695109aa15da0a07b5b5fb" + integrity sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg== + dependencies: + tslib "^2.6.2" + "@aws-crypto/util@^5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-5.2.0.tgz#71284c9cffe7927ddadac793c14f14886d3876da" @@ -104,12 +124,517 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-sdk/types@^3.222.0": - version "3.577.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.577.0.tgz#7700784d368ce386745f8c340d9d68cea4716f90" - integrity sha512-FT2JZES3wBKN/alfmhlo+3ZOq/XJ0C7QOZcDNrpKjB0kqYoKjhVKZ/Hx6ArR0czkKfHzBBEs6y40ebIHx2nSmA== +"@aws-sdk/client-bedrock-agent-runtime@^3.616.0": + version "3.688.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agent-runtime/-/client-bedrock-agent-runtime-3.688.0.tgz#81769a896ff678d913e2838a554a9060ce3db3ab" + integrity sha512-ZaIX7nBQm2QIrl0TNgPtYvEJbMDUfFB1AT/ToKQ1IEKI3gc0tIgSdcxqorpXer+s50ZB3j9ITF4WCyhWnxfNSw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/client-sso-oidc" "3.687.0" + "@aws-sdk/client-sts" "3.687.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/credential-provider-node" "3.687.0" + "@aws-sdk/middleware-host-header" "3.686.0" + "@aws-sdk/middleware-logger" "3.686.0" + "@aws-sdk/middleware-recursion-detection" "3.686.0" + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/region-config-resolver" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@aws-sdk/util-user-agent-browser" "3.686.0" + "@aws-sdk/util-user-agent-node" "3.687.0" + "@smithy/config-resolver" "^3.0.10" + "@smithy/core" "^2.5.1" + "@smithy/eventstream-serde-browser" "^3.0.11" + "@smithy/eventstream-serde-config-resolver" "^3.0.8" + "@smithy/eventstream-serde-node" "^3.0.10" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/hash-node" "^3.0.8" + "@smithy/invalid-dependency" "^3.0.8" + "@smithy/middleware-content-length" "^3.0.10" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-retry" "^3.0.25" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-body-length-node" "^3.0.0" + "@smithy/util-defaults-mode-browser" "^3.0.25" + "@smithy/util-defaults-mode-node" "^3.0.25" + "@smithy/util-endpoints" "^2.1.4" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-bedrock-runtime@^3.602.0", "@aws-sdk/client-bedrock-runtime@^3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.687.0.tgz#9c08850b2cebe62da0682f76c7a5559e53829325" + integrity sha512-ayFDpIOXVOeY84CPo9KCY2emEPjLBNFT8TFeZeUjz8KiV+K0LwAKnkbLQkTweHFN2sq2pa7XqAPZ70xMjt5w3w== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/client-sso-oidc" "3.687.0" + "@aws-sdk/client-sts" "3.687.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/credential-provider-node" "3.687.0" + "@aws-sdk/middleware-host-header" "3.686.0" + "@aws-sdk/middleware-logger" "3.686.0" + "@aws-sdk/middleware-recursion-detection" "3.686.0" + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/region-config-resolver" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@aws-sdk/util-user-agent-browser" "3.686.0" + "@aws-sdk/util-user-agent-node" "3.687.0" + "@smithy/config-resolver" "^3.0.10" + "@smithy/core" "^2.5.1" + "@smithy/eventstream-serde-browser" "^3.0.11" + "@smithy/eventstream-serde-config-resolver" "^3.0.8" + "@smithy/eventstream-serde-node" "^3.0.10" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/hash-node" "^3.0.8" + "@smithy/invalid-dependency" "^3.0.8" + "@smithy/middleware-content-length" "^3.0.10" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-retry" "^3.0.25" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-body-length-node" "^3.0.0" + "@smithy/util-defaults-mode-browser" "^3.0.25" + "@smithy/util-defaults-mode-node" "^3.0.25" + "@smithy/util-endpoints" "^2.1.4" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + "@smithy/util-stream" "^3.2.1" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-kendra@^3.352.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-kendra/-/client-kendra-3.687.0.tgz#b55cd41694fb49ae3d0c4a47401752c322b5bafb" + integrity sha512-NreNmI6OIcuRGgtmjXiceXwcf1TPUIdg+rlPJwLFrTi6ukIu+P9e28g2ggNtZQ9pYmyUilBl2XntLIKHqvQAnQ== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/client-sso-oidc" "3.687.0" + "@aws-sdk/client-sts" "3.687.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/credential-provider-node" "3.687.0" + "@aws-sdk/middleware-host-header" "3.686.0" + "@aws-sdk/middleware-logger" "3.686.0" + "@aws-sdk/middleware-recursion-detection" "3.686.0" + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/region-config-resolver" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@aws-sdk/util-user-agent-browser" "3.686.0" + "@aws-sdk/util-user-agent-node" "3.687.0" + "@smithy/config-resolver" "^3.0.10" + "@smithy/core" "^2.5.1" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/hash-node" "^3.0.8" + "@smithy/invalid-dependency" "^3.0.8" + "@smithy/middleware-content-length" "^3.0.10" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-retry" "^3.0.25" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-body-length-node" "^3.0.0" + "@smithy/util-defaults-mode-browser" "^3.0.25" + "@smithy/util-defaults-mode-node" "^3.0.25" + "@smithy/util-endpoints" "^2.1.4" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + "@smithy/util-utf8" "^3.0.0" + "@types/uuid" "^9.0.1" + tslib "^2.6.2" + uuid "^9.0.1" + +"@aws-sdk/client-sso-oidc@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.687.0.tgz#a327cc65b7bb2cbda305c4467bfae452b5d27927" + integrity sha512-Rdd8kLeTeh+L5ZuG4WQnWgYgdv7NorytKdZsGjiag1D8Wv3PcJvPqqWdgnI0Og717BSXVoaTYaN34FyqFYSx6Q== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/credential-provider-node" "3.687.0" + "@aws-sdk/middleware-host-header" "3.686.0" + "@aws-sdk/middleware-logger" "3.686.0" + "@aws-sdk/middleware-recursion-detection" "3.686.0" + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/region-config-resolver" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@aws-sdk/util-user-agent-browser" "3.686.0" + "@aws-sdk/util-user-agent-node" "3.687.0" + "@smithy/config-resolver" "^3.0.10" + "@smithy/core" "^2.5.1" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/hash-node" "^3.0.8" + "@smithy/invalid-dependency" "^3.0.8" + "@smithy/middleware-content-length" "^3.0.10" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-retry" "^3.0.25" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-body-length-node" "^3.0.0" + "@smithy/util-defaults-mode-browser" "^3.0.25" + "@smithy/util-defaults-mode-node" "^3.0.25" + "@smithy/util-endpoints" "^2.1.4" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-sso@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.687.0.tgz#4c71b818e718f632aa3dd4047961bededa23e4a7" + integrity sha512-dfj0y9fQyX4kFill/ZG0BqBTLQILKlL7+O5M4F9xlsh2WNuV2St6WtcOg14Y1j5UODPJiJs//pO+mD1lihT5Kw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/middleware-host-header" "3.686.0" + "@aws-sdk/middleware-logger" "3.686.0" + "@aws-sdk/middleware-recursion-detection" "3.686.0" + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/region-config-resolver" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@aws-sdk/util-user-agent-browser" "3.686.0" + "@aws-sdk/util-user-agent-node" "3.687.0" + "@smithy/config-resolver" "^3.0.10" + "@smithy/core" "^2.5.1" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/hash-node" "^3.0.8" + "@smithy/invalid-dependency" "^3.0.8" + "@smithy/middleware-content-length" "^3.0.10" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-retry" "^3.0.25" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-body-length-node" "^3.0.0" + "@smithy/util-defaults-mode-browser" "^3.0.25" + "@smithy/util-defaults-mode-node" "^3.0.25" + "@smithy/util-endpoints" "^2.1.4" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-sts@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.687.0.tgz#fcb837080b225c5820f08326e98db54e48606fb1" + integrity sha512-SQjDH8O4XCTtouuCVYggB0cCCrIaTzUZIkgJUpOsIEJBLlTbNOb/BZqUShAQw2o9vxr2rCeOGjAQOYPysW/Pmg== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/client-sso-oidc" "3.687.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/credential-provider-node" "3.687.0" + "@aws-sdk/middleware-host-header" "3.686.0" + "@aws-sdk/middleware-logger" "3.686.0" + "@aws-sdk/middleware-recursion-detection" "3.686.0" + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/region-config-resolver" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@aws-sdk/util-user-agent-browser" "3.686.0" + "@aws-sdk/util-user-agent-node" "3.687.0" + "@smithy/config-resolver" "^3.0.10" + "@smithy/core" "^2.5.1" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/hash-node" "^3.0.8" + "@smithy/invalid-dependency" "^3.0.8" + "@smithy/middleware-content-length" "^3.0.10" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-retry" "^3.0.25" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-body-length-node" "^3.0.0" + "@smithy/util-defaults-mode-browser" "^3.0.25" + "@smithy/util-defaults-mode-node" "^3.0.25" + "@smithy/util-endpoints" "^2.1.4" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@aws-sdk/core@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.686.0.tgz#106a3733c250094db15ba765386db4643f5613b6" + integrity sha512-Xt3DV4DnAT3v2WURwzTxWQK34Ew+iiLzoUoguvLaZrVMFOqMMrwVjP+sizqIaHp1j7rGmFcN5I8saXnsDLuQLA== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/core" "^2.5.1" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/property-provider" "^3.1.7" + "@smithy/protocol-http" "^4.1.5" + "@smithy/signature-v4" "^4.2.0" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/util-middleware" "^3.0.8" + fast-xml-parser "4.4.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.686.0.tgz#71ce2df0be065dacddd873d1be7426bc8c6038ec" + integrity sha512-osD7lPO8OREkgxPiTWmA1i6XEmOth1uW9HWWj/+A2YGCj1G/t2sHu931w4Qj9NWHYZtbTTXQYVRg+TErALV7nQ== + dependencies: + "@aws-sdk/core" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/property-provider" "^3.1.7" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.686.0.tgz#fe84ea67fea6bb61effc0f10b99a0c3e9378d6c3" + integrity sha512-xyGAD/f3vR/wssUiZrNFWQWXZvI4zRm2wpHhoHA1cC2fbRMNFYtFn365yw6dU7l00ZLcdFB1H119AYIUZS7xbw== + dependencies: + "@aws-sdk/core" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/property-provider" "^3.1.7" + "@smithy/protocol-http" "^4.1.5" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/util-stream" "^3.2.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.687.0.tgz#adb7f3fe381767ad1a4aee352162630f7b5f54de" + integrity sha512-6d5ZJeZch+ZosJccksN0PuXv7OSnYEmanGCnbhUqmUSz9uaVX6knZZfHCZJRgNcfSqg9QC0zsFA/51W5HCUqSQ== + dependencies: + "@aws-sdk/core" "3.686.0" + "@aws-sdk/credential-provider-env" "3.686.0" + "@aws-sdk/credential-provider-http" "3.686.0" + "@aws-sdk/credential-provider-process" "3.686.0" + "@aws-sdk/credential-provider-sso" "3.687.0" + "@aws-sdk/credential-provider-web-identity" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/credential-provider-imds" "^3.2.4" + "@smithy/property-provider" "^3.1.7" + "@smithy/shared-ini-file-loader" "^3.1.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@3.687.0", "@aws-sdk/credential-provider-node@^3.600.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.687.0.tgz#46bd8014bb68913ad285aed01e6920083a42d056" + integrity sha512-Pqld8Nx11NYaBUrVk3bYiGGpLCxkz8iTONlpQWoVWFhSOzlO7zloNOaYbD2XgFjjqhjlKzE91drs/f41uGeCTA== + dependencies: + "@aws-sdk/credential-provider-env" "3.686.0" + "@aws-sdk/credential-provider-http" "3.686.0" + "@aws-sdk/credential-provider-ini" "3.687.0" + "@aws-sdk/credential-provider-process" "3.686.0" + "@aws-sdk/credential-provider-sso" "3.687.0" + "@aws-sdk/credential-provider-web-identity" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/credential-provider-imds" "^3.2.4" + "@smithy/property-provider" "^3.1.7" + "@smithy/shared-ini-file-loader" "^3.1.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.686.0.tgz#7b02591d9b81fb16288618ce23d3244496c1b538" + integrity sha512-sXqaAgyzMOc+dm4CnzAR5Q6S9OWVHyZjLfW6IQkmGjqeQXmZl24c4E82+w64C+CTkJrFLzH1VNOYp1Hy5gE6Qw== + dependencies: + "@aws-sdk/core" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/property-provider" "^3.1.7" + "@smithy/shared-ini-file-loader" "^3.1.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.687.0.tgz#2e5704bdaa3c420c2a00a1316cdbdf57d78ae649" + integrity sha512-N1YCoE7DovIRF2ReyRrA4PZzF0WNi4ObPwdQQkVxhvSm7PwjbWxrfq7rpYB+6YB1Uq3QPzgVwUFONE36rdpxUQ== + dependencies: + "@aws-sdk/client-sso" "3.687.0" + "@aws-sdk/core" "3.686.0" + "@aws-sdk/token-providers" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/property-provider" "^3.1.7" + "@smithy/shared-ini-file-loader" "^3.1.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.686.0.tgz#228be45b2f840ebf227d96ee5e326c1efa3c25a9" + integrity sha512-40UqCpPxyHCXDP7CGd9JIOZDgDZf+u1OyLaGBpjQJlz1HYuEsIWnnbTe29Yg3Ah/Zc3g4NBWcUdlGVotlnpnDg== + dependencies: + "@aws-sdk/core" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@smithy/property-provider" "^3.1.7" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-host-header@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.686.0.tgz#16f0be33fc738968a4e10ff77cb8a04e2b2c2359" + integrity sha512-+Yc6rO02z+yhFbHmRZGvEw1vmzf/ifS9a4aBjJGeVVU+ZxaUvnk+IUZWrj4YQopUQ+bSujmMUzJLXSkbDq7yuw== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-logger@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.686.0.tgz#4e094e42e10bf17d43b9c9afc3fc594f4aa72e02" + integrity sha512-cX43ODfA2+SPdX7VRxu6gXk4t4bdVJ9pkktbfnkE5t27OlwNfvSGGhnHrQL8xTOFeyQ+3T+oowf26gf1OI+vIg== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-recursion-detection@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.686.0.tgz#aba097d2dcc9d3b9d4523d7ae03ac3b387617db1" + integrity sha512-jF9hQ162xLgp9zZ/3w5RUNhmwVnXDBlABEUX8jCgzaFpaa742qR/KKtjjZQ6jMbQnP+8fOCSXFAVNMU+s6v81w== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-user-agent@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.687.0.tgz#a5feb5466d2926cd1ef5dd6f4778b33ce160ca7f" + integrity sha512-nUgsKiEinyA50CaDXojAkOasAU3Apdg7Qox6IjNUC4ZjgOu7QWsCDB5N28AYMUt06cNYeYQdfMX1aEzG85a1Mg== + dependencies: + "@aws-sdk/core" "3.686.0" + "@aws-sdk/types" "3.686.0" + "@aws-sdk/util-endpoints" "3.686.0" + "@smithy/core" "^2.5.1" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/region-config-resolver@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.686.0.tgz#3ef61e2cd95eb0ae80ecd5eef284744eb0a76d7c" + integrity sha512-6zXD3bSD8tcsMAVVwO1gO7rI1uy2fCD3czgawuPGPopeLiPpo6/3FoUWCQzk2nvEhj7p9Z4BbjwZGSlRkVrXTw== dependencies: - "@smithy/types" "^3.0.0" + "@aws-sdk/types" "3.686.0" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/types" "^3.6.0" + "@smithy/util-config-provider" "^3.0.0" + "@smithy/util-middleware" "^3.0.8" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.686.0.tgz#c7733a0a079adc9404bd9d8fc4ff52edef0a123a" + integrity sha512-9oL4kTCSePFmyKPskibeiOXV6qavPZ63/kXM9Wh9V6dTSvBtLeNnMxqGvENGKJcTdIgtoqyqA6ET9u0PJ5IRIg== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/property-provider" "^3.1.7" + "@smithy/shared-ini-file-loader" "^3.1.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/types@3.686.0", "@aws-sdk/types@^3.222.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.686.0.tgz#01aa5307c727de9e69969c538f99ae8b53f1074f" + integrity sha512-xFnrb3wxOoJcW2Xrh63ZgFo5buIu9DF7bOHnwoUxHdNpUXicUh0AHw85TjXxyxIAd0d1psY/DU7QHoNI3OswgQ== + dependencies: + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@aws-sdk/util-endpoints@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.686.0.tgz#c9a621961b8efda6d82ab3523d673acb0629d6d0" + integrity sha512-7msZE2oYl+6QYeeRBjlDgxQUhq/XRky3cXE0FqLFs2muLS7XSuQEXkpOXB3R782ygAP6JX0kmBxPTLurRTikZg== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/types" "^3.6.0" + "@smithy/util-endpoints" "^2.1.4" + tslib "^2.6.2" + +"@aws-sdk/util-locate-window@^3.0.0": + version "3.679.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.679.0.tgz#8d5898624691e12ccbad839e103562002bbec85e" + integrity sha512-zKTd48/ZWrCplkXpYDABI74rQlbR0DNHs8nH95htfSLj9/mWRSwaGptoxwcihaq/77vi/fl2X3y0a1Bo8bt7RA== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/util-user-agent-browser@3.686.0": + version "3.686.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.686.0.tgz#953ef68c1b54e02f9de742310f47c33452f088bc" + integrity sha512-YiQXeGYZegF1b7B2GOR61orhgv79qmI0z7+Agm3NXLO6hGfVV3kFUJbXnjtH1BgWo5hbZYW7HQ2omGb3dnb6Lg== + dependencies: + "@aws-sdk/types" "3.686.0" + "@smithy/types" "^3.6.0" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/util-user-agent-node@3.687.0": + version "3.687.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.687.0.tgz#6bdc45c2ef776a86614b002867aef37fc6f45b41" + integrity sha512-idkP6ojSTZ4ek1pJ8wIN7r9U3KR5dn0IkJn3KQBXQ58LWjkRqLtft2vxzdsktWwhPKjjmIKl1S0kbvqLawf8XQ== + dependencies: + "@aws-sdk/middleware-user-agent" "3.687.0" + "@aws-sdk/types" "3.686.0" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/types" "^3.6.0" tslib "^2.6.2" "@babel/cli@^7.24.7": @@ -7360,10 +7885,22 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== -"@langchain/community@0.3.11": - version "0.3.11" - resolved "https://registry.yarnpkg.com/@langchain/community/-/community-0.3.11.tgz#cb0f188f4e72c00beb1efdbd1fc7d7f47b70e636" - integrity sha512-hgnqsgWAhfUj9Kp0y+FGxlKot/qJFxat9GfIPJSJU4ViN434PgeMAQK53tkGZ361E2Zoo1V4RoGlSw4AjJILiA== +"@langchain/aws@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@langchain/aws/-/aws-0.1.2.tgz#607ab6d2f87c07a64176e6341ae2e9f857027b95" + integrity sha512-1cQvv8XSbaZXceAbYexSm/8WLqfEJ4VF6qbf/XLwkpUKMFGqpSBA00+Bn5p8K/Ms+PyMguZrxVNqd6daqxhDBQ== + dependencies: + "@aws-sdk/client-bedrock-agent-runtime" "^3.616.0" + "@aws-sdk/client-bedrock-runtime" "^3.602.0" + "@aws-sdk/client-kendra" "^3.352.0" + "@aws-sdk/credential-provider-node" "^3.600.0" + zod "^3.23.8" + zod-to-json-schema "^3.22.5" + +"@langchain/community@0.3.14": + version "0.3.14" + resolved "https://registry.yarnpkg.com/@langchain/community/-/community-0.3.14.tgz#33c9c907f2a8cccc0af7fdeab50b2b69d85321ac" + integrity sha512-zadvK0pu15Jp028VEV4wV+lYB1ViojSolSdSNMdE82KuaK97kH/F1aynQ2W+ebHzjr0lG3dUF3OfOqHU37VgwA== dependencies: "@langchain/openai" ">=0.2.0 <0.4.0" binary-extensions "^2.2.0" @@ -8728,32 +9265,122 @@ "@types/node" ">=18.0.0" axios "^1.6.0" -"@smithy/eventstream-codec@^3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-3.1.1.tgz#b47f30bf4ad791ac7981b9fff58e599d18269cf9" - integrity sha512-s29NxV/ng1KXn6wPQ4qzJuQDjEtxLdS0+g5PQFirIeIZrp66FXVJ5IpZRowbt/42zB5dY8TqJ0G0L9KkgtsEZg== +"@smithy/abort-controller@^3.1.8": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-3.1.8.tgz#ce0c10ddb2b39107d70b06bbb8e4f6e368bc551d" + integrity sha512-+3DOBcUn5/rVjlxGvUPKc416SExarAQ+Qe0bqk30YSUjbepwpS7QN0cyKUSifvLJhdMZ0WPzPP5ymut0oonrpQ== + dependencies: + "@smithy/types" "^3.7.1" + tslib "^2.6.2" + +"@smithy/config-resolver@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-3.0.10.tgz#d9529d9893e5fae1f14cb1ffd55517feb6d7e50f" + integrity sha512-Uh0Sz9gdUuz538nvkPiyv1DZRX9+D15EKDtnQP5rYVAzM/dnYk3P8cg73jcxyOitPgT3mE3OVj7ky7sibzHWkw== + dependencies: + "@smithy/node-config-provider" "^3.1.9" + "@smithy/types" "^3.6.0" + "@smithy/util-config-provider" "^3.0.0" + "@smithy/util-middleware" "^3.0.8" + tslib "^2.6.2" + +"@smithy/core@^2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-2.5.1.tgz#7f635b76778afca845bcb401d36f22fa37712f15" + integrity sha512-DujtuDA7BGEKExJ05W5OdxCoyekcKT3Rhg1ZGeiUWaz2BJIWXjZmsG/DIP4W48GHno7AQwRsaCb8NcBgH3QZpg== + dependencies: + "@smithy/middleware-serde" "^3.0.8" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + "@smithy/util-body-length-browser" "^3.0.0" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-stream" "^3.2.1" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^3.2.4", "@smithy/credential-provider-imds@^3.2.5": + version "3.2.5" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.5.tgz#dbfd849a4a7ebd68519cd9fc35f78d091e126d0a" + integrity sha512-4FTQGAsuwqTzVMmiRVTn0RR9GrbRfkP0wfu/tXWVHd2LgNpTY0uglQpIScXK4NaEyXbB3JmZt8gfVqO50lP8wg== + dependencies: + "@smithy/node-config-provider" "^3.1.9" + "@smithy/property-provider" "^3.1.8" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + tslib "^2.6.2" + +"@smithy/eventstream-codec@^3.1.1", "@smithy/eventstream-codec@^3.1.7": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-3.1.7.tgz#5bfaffbc83ae374ffd85a755a8200ba3c7aed016" + integrity sha512-kVSXScIiRN7q+s1x7BrQtZ1Aa9hvvP9FeCqCdBxv37GimIHgBCOnZ5Ip80HLt0DhnAKpiobFdGqTFgbaJNrazA== dependencies: "@aws-crypto/crc32" "5.2.0" - "@smithy/types" "^3.2.0" + "@smithy/types" "^3.6.0" "@smithy/util-hex-encoding" "^3.0.0" tslib "^2.6.2" -"@smithy/eventstream-serde-node@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.3.tgz#51df0ca39f453d78a3d6607c1ac2e96cf900c824" - integrity sha512-v61Ftn7x/ubWFqH7GHFAL/RaU7QZImTbuV95DYugYYItzpO7KaHYEuO8EskCaBpZEfzOxhUGKm4teS9YUSt69Q== +"@smithy/eventstream-serde-browser@^3.0.11": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.11.tgz#019f3d1016d893b65ef6efec8c5e2fa925d0ac3d" + integrity sha512-Pd1Wnq3CQ/v2SxRifDUihvpXzirJYbbtXfEnnLV/z0OGCTx/btVX74P86IgrZkjOydOASBGXdPpupYQI+iO/6A== dependencies: - "@smithy/eventstream-serde-universal" "^3.0.3" - "@smithy/types" "^3.2.0" + "@smithy/eventstream-serde-universal" "^3.0.10" + "@smithy/types" "^3.6.0" tslib "^2.6.2" -"@smithy/eventstream-serde-universal@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.3.tgz#2ecac479ba84e10221b4b70545f3d7a223b5345e" - integrity sha512-YXYt3Cjhu9tRrahbTec2uOjwOSeCNfQurcWPGNEUspBhqHoA3KrDrVj+jGbCLWvwkwhzqDnnaeHAxm+IxAjOAQ== +"@smithy/eventstream-serde-config-resolver@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.8.tgz#bba17a358818e61993aaa73e36ea4023c5805556" + integrity sha512-zkFIG2i1BLbfoGQnf1qEeMqX0h5qAznzaZmMVNnvPZz9J5AWBPkOMckZWPedGUPcVITacwIdQXoPcdIQq5FRcg== dependencies: - "@smithy/eventstream-codec" "^3.1.1" - "@smithy/types" "^3.2.0" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/eventstream-serde-node@^3.0.10", "@smithy/eventstream-serde-node@^3.0.3": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.10.tgz#da40b872001390bb47807186855faba8172b3b5b" + integrity sha512-hjpU1tIsJ9qpcoZq9zGHBJPBOeBGYt+n8vfhDwnITPhEre6APrvqq/y3XMDEGUT2cWQ4ramNqBPRbx3qn55rhw== + dependencies: + "@smithy/eventstream-serde-universal" "^3.0.10" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/eventstream-serde-universal@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.10.tgz#b24e66fec9ec003eb0a1d6733fa22ded43129281" + integrity sha512-ewG1GHbbqsFZ4asaq40KmxCmXO+AFSM1b+DcO2C03dyJj/ZH71CiTg853FSE/3SHK9q3jiYQIFjlGSwfxQ9kww== + dependencies: + "@smithy/eventstream-codec" "^3.1.7" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-4.0.0.tgz#3763cb5178745ed630ed5bc3beb6328abdc31f36" + integrity sha512-MLb1f5tbBO2X6K4lMEKJvxeLooyg7guq48C2zKr4qM7F2Gpkz4dc+hdSgu77pCJ76jVqFBjZczHYAs6dp15N+g== + dependencies: + "@smithy/protocol-http" "^4.1.5" + "@smithy/querystring-builder" "^3.0.8" + "@smithy/types" "^3.6.0" + "@smithy/util-base64" "^3.0.0" + tslib "^2.6.2" + +"@smithy/hash-node@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-3.0.8.tgz#f451cc342f74830466b0b39bf985dc3022634065" + integrity sha512-tlNQYbfpWXHimHqrvgo14DrMAgUBua/cNoz9fMYcDmYej7MAmUcjav/QKQbFc3NrcPxeJ7QClER4tWZmfwoPng== + dependencies: + "@smithy/types" "^3.6.0" + "@smithy/util-buffer-from" "^3.0.0" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@smithy/invalid-dependency@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-3.0.8.tgz#4d381a4c24832371ade79e904a72c173c9851e5f" + integrity sha512-7Qynk6NWtTQhnGTTZwks++nJhQ1O54Mzi7fz4PqZOiYXb4Z1Flpb2yRvdALoggTS8xjtohWUM+RygOtB30YL3Q== + dependencies: + "@smithy/types" "^3.6.0" tslib "^2.6.2" "@smithy/is-array-buffer@^2.0.0": @@ -8770,12 +9397,127 @@ dependencies: tslib "^2.6.2" -"@smithy/protocol-http@^4.0.2": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-4.0.2.tgz#502ed3116cb0f1e3f207881df965bac620ccb2da" - integrity sha512-X/90xNWIOqSR2tLUyWxVIBdatpm35DrL44rI/xoeBWUuanE0iyCXJpTcnqlOpnEzgcu0xCKE06+g70TTu2j7RQ== +"@smithy/middleware-content-length@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-3.0.10.tgz#738266f6d81436d7e3a86bea931bc64e04ae7dbf" + integrity sha512-T4dIdCs1d/+/qMpwhJ1DzOhxCZjZHbHazEPJWdB4GDi2HjIZllVzeBEcdJUN0fomV8DURsgOyrbEUzg3vzTaOg== dependencies: - "@smithy/types" "^3.2.0" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/middleware-endpoint@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-3.2.1.tgz#b9ee42d29d8f3a266883d293c4d6a586f7b60979" + integrity sha512-wWO3xYmFm6WRW8VsEJ5oU6h7aosFXfszlz3Dj176pTij6o21oZnzkCLzShfmRaaCHDkBXWBdO0c4sQAvLFP6zA== + dependencies: + "@smithy/core" "^2.5.1" + "@smithy/middleware-serde" "^3.0.8" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/shared-ini-file-loader" "^3.1.9" + "@smithy/types" "^3.6.0" + "@smithy/url-parser" "^3.0.8" + "@smithy/util-middleware" "^3.0.8" + tslib "^2.6.2" + +"@smithy/middleware-retry@^3.0.25": + version "3.0.25" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-3.0.25.tgz#a6b1081fc1a0991ffe1d15e567e76198af01f37c" + integrity sha512-m1F70cPaMBML4HiTgCw5I+jFNtjgz5z5UdGnUbG37vw6kh4UvizFYjqJGHvicfgKMkDL6mXwyPp5mhZg02g5sg== + dependencies: + "@smithy/node-config-provider" "^3.1.9" + "@smithy/protocol-http" "^4.1.5" + "@smithy/service-error-classification" "^3.0.8" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-retry" "^3.0.8" + tslib "^2.6.2" + uuid "^9.0.1" + +"@smithy/middleware-serde@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-3.0.8.tgz#a46d10dba3c395be0d28610d55c89ff8c07c0cd3" + integrity sha512-Xg2jK9Wc/1g/MBMP/EUn2DLspN8LNt+GMe7cgF+Ty3vl+Zvu+VeZU5nmhveU+H8pxyTsjrAkci8NqY6OuvZnjA== + dependencies: + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/middleware-stack@^3.0.10", "@smithy/middleware-stack@^3.0.8": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-3.0.10.tgz#73e2fde5d151440844161773a17ee13375502baf" + integrity sha512-grCHyoiARDBBGPyw2BeicpjgpsDFWZZxptbVKb3CRd/ZA15F/T6rZjCCuBUjJwdck1nwUuIxYtsS4H9DDpbP5w== + dependencies: + "@smithy/types" "^3.7.1" + tslib "^2.6.2" + +"@smithy/node-config-provider@^3.1.9": + version "3.1.9" + resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-3.1.9.tgz#d27ba8e4753f1941c24ed0af824dbc6c492f510a" + integrity sha512-qRHoah49QJ71eemjuS/WhUXB+mpNtwHRWQr77J/m40ewBVVwvo52kYAmb7iuaECgGTTcYxHS4Wmewfwy++ueew== + dependencies: + "@smithy/property-provider" "^3.1.8" + "@smithy/shared-ini-file-loader" "^3.1.9" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/node-http-handler@^3.2.5", "@smithy/node-http-handler@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-3.3.1.tgz#788fc1c22c21a0cf982f4025ccf9f64217f3164f" + integrity sha512-fr+UAOMGWh6bn4YSEezBCpJn9Ukp9oR4D32sCjCo7U81evE11YePOQ58ogzyfgmjIO79YeOdfXXqr0jyhPQeMg== + dependencies: + "@smithy/abort-controller" "^3.1.8" + "@smithy/protocol-http" "^4.1.7" + "@smithy/querystring-builder" "^3.0.10" + "@smithy/types" "^3.7.1" + tslib "^2.6.2" + +"@smithy/property-provider@^3.1.7", "@smithy/property-provider@^3.1.8": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-3.1.8.tgz#b1c5a3949effbb9772785ad7ddc5b4b235b10fbe" + integrity sha512-ukNUyo6rHmusG64lmkjFeXemwYuKge1BJ8CtpVKmrxQxc6rhUX0vebcptFA9MmrGsnLhwnnqeH83VTU9hwOpjA== + dependencies: + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/protocol-http@^4.1.5", "@smithy/protocol-http@^4.1.7": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-4.1.7.tgz#5c67e62beb5deacdb94f2127f9a344bdf1b2ed6e" + integrity sha512-FP2LepWD0eJeOTm0SjssPcgqAlDFzOmRXqXmGhfIM52G7Lrox/pcpQf6RP4F21k0+O12zaqQt5fCDOeBtqY6Cg== + dependencies: + "@smithy/types" "^3.7.1" + tslib "^2.6.2" + +"@smithy/querystring-builder@^3.0.10", "@smithy/querystring-builder@^3.0.8": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-3.0.10.tgz#db8773af85ee3977c82b8e35a5cdd178c621306d" + integrity sha512-nT9CQF3EIJtIUepXQuBFb8dxJi3WVZS3XfuDksxSCSn+/CzZowRLdhDn+2acbBv8R6eaJqPupoI/aRFIImNVPQ== + dependencies: + "@smithy/types" "^3.7.1" + "@smithy/util-uri-escape" "^3.0.0" + tslib "^2.6.2" + +"@smithy/querystring-parser@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-3.0.8.tgz#057a8e2d301eea8eac7071923100ba38a824d7df" + integrity sha512-BtEk3FG7Ks64GAbt+JnKqwuobJNX8VmFLBsKIwWr1D60T426fGrV2L3YS5siOcUhhp6/Y6yhBw1PSPxA5p7qGg== + dependencies: + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/service-error-classification@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-3.0.8.tgz#265ad2573b972f6c7bdd1ad6c5155a88aeeea1c4" + integrity sha512-uEC/kCCFto83bz5ZzapcrgGqHOh/0r69sZ2ZuHlgoD5kYgXJEThCoTuw/y1Ub3cE7aaKdznb+jD9xRPIfIwD7g== + dependencies: + "@smithy/types" "^3.6.0" + +"@smithy/shared-ini-file-loader@^3.1.8", "@smithy/shared-ini-file-loader@^3.1.9": + version "3.1.9" + resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.9.tgz#1b77852b5bb176445e1d80333fa3f739313a4928" + integrity sha512-/+OsJRNtoRbtsX0UpSgWVxFZLsJHo/4sTr+kBg/J78sr7iC+tHeOvOJrS5hCpVQ6sWBbhWLp1UNiuMyZhE6pmA== + dependencies: + "@smithy/types" "^3.6.0" tslib "^2.6.2" "@smithy/signature-v4@^3.1.1": @@ -8791,10 +9533,69 @@ "@smithy/util-utf8" "^3.0.0" tslib "^2.6.2" -"@smithy/types@^3.0.0", "@smithy/types@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@smithy/types/-/types-3.2.0.tgz#1350fe8a50d5e35e12ffb34be46d946860b2b5ab" - integrity sha512-cKyeKAPazZRVqm7QPvcPD2jEIt2wqDPAL1KJKb0f/5I7uhollvsWZuZKLclmyP6a+Jwmr3OV3t+X0pZUUHS9BA== +"@smithy/signature-v4@^4.2.0": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-4.2.1.tgz#a918fd7d99af9f60aa07617506fa54be408126ee" + integrity sha512-NsV1jF4EvmO5wqmaSzlnTVetemBS3FZHdyc5CExbDljcyJCEEkJr8ANu2JvtNbVg/9MvKAWV44kTrGS+Pi4INg== + dependencies: + "@smithy/is-array-buffer" "^3.0.0" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + "@smithy/util-hex-encoding" "^3.0.0" + "@smithy/util-middleware" "^3.0.8" + "@smithy/util-uri-escape" "^3.0.0" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@smithy/smithy-client@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-3.4.2.tgz#a6e3ed98330ce170cf482e765bd0c21e0fde8ae4" + integrity sha512-dxw1BDxJiY9/zI3cBqfVrInij6ShjpV4fmGHesGZZUiP9OSE/EVfdwdRz0PgvkEvrZHpsj2htRaHJfftE8giBA== + dependencies: + "@smithy/core" "^2.5.1" + "@smithy/middleware-endpoint" "^3.2.1" + "@smithy/middleware-stack" "^3.0.8" + "@smithy/protocol-http" "^4.1.5" + "@smithy/types" "^3.6.0" + "@smithy/util-stream" "^3.2.1" + tslib "^2.6.2" + +"@smithy/types@^3.2.0", "@smithy/types@^3.6.0", "@smithy/types@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-3.7.1.tgz#4af54c4e28351e9101996785a33f2fdbf93debe7" + integrity sha512-XKLcLXZY7sUQgvvWyeaL/qwNPp6V3dWcUjqrQKjSb+tzYiCy340R/c64LV5j+Tnb2GhmunEX0eou+L+m2hJNYA== + dependencies: + tslib "^2.6.2" + +"@smithy/url-parser@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-3.0.8.tgz#8057d91d55ba8df97d74576e000f927b42da9e18" + integrity sha512-4FdOhwpTW7jtSFWm7SpfLGKIBC9ZaTKG5nBF0wK24aoQKQyDIKUw3+KFWCQ9maMzrgTJIuOvOnsV2lLGW5XjTg== + dependencies: + "@smithy/querystring-parser" "^3.0.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/util-base64@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-3.0.0.tgz#f7a9a82adf34e27a72d0719395713edf0e493017" + integrity sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ== + dependencies: + "@smithy/util-buffer-from" "^3.0.0" + "@smithy/util-utf8" "^3.0.0" + tslib "^2.6.2" + +"@smithy/util-body-length-browser@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz#86ec2f6256310b4845a2f064e2f571c1ca164ded" + integrity sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ== + dependencies: + tslib "^2.6.2" + +"@smithy/util-body-length-node@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz#99a291bae40d8932166907fe981d6a1f54298a6d" + integrity sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA== dependencies: tslib "^2.6.2" @@ -8814,6 +9615,46 @@ "@smithy/is-array-buffer" "^3.0.0" tslib "^2.6.2" +"@smithy/util-config-provider@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz#62c6b73b22a430e84888a8f8da4b6029dd5b8efe" + integrity sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ== + dependencies: + tslib "^2.6.2" + +"@smithy/util-defaults-mode-browser@^3.0.25": + version "3.0.25" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.25.tgz#ef9b84272d1db23503ff155f9075a4543ab6dab7" + integrity sha512-fRw7zymjIDt6XxIsLwfJfYUfbGoO9CmCJk6rjJ/X5cd20+d2Is7xjU5Kt/AiDt6hX8DAf5dztmfP5O82gR9emA== + dependencies: + "@smithy/property-provider" "^3.1.8" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + bowser "^2.11.0" + tslib "^2.6.2" + +"@smithy/util-defaults-mode-node@^3.0.25": + version "3.0.25" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.25.tgz#c16fe3995c8e90ae318e336178392173aebe1e37" + integrity sha512-H3BSZdBDiVZGzt8TG51Pd2FvFO0PAx/A0mJ0EH8a13KJ6iUCdYnw/Dk/MdC1kTd0eUuUGisDFaxXVXo4HHFL1g== + dependencies: + "@smithy/config-resolver" "^3.0.10" + "@smithy/credential-provider-imds" "^3.2.5" + "@smithy/node-config-provider" "^3.1.9" + "@smithy/property-provider" "^3.1.8" + "@smithy/smithy-client" "^3.4.2" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/util-endpoints@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-2.1.4.tgz#a29134c2b1982442c5fc3be18d9b22796e8eb964" + integrity sha512-kPt8j4emm7rdMWQyL0F89o92q10gvCUa6sBkBtDJ7nV2+P7wpXczzOfoDJ49CKXe5CCqb8dc1W+ZdLlrKzSAnQ== + dependencies: + "@smithy/node-config-provider" "^3.1.9" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + "@smithy/util-hex-encoding@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz#32938b33d5bf2a15796cd3f178a55b4155c535e6" @@ -8821,12 +9662,35 @@ dependencies: tslib "^2.6.2" -"@smithy/util-middleware@^3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-3.0.2.tgz#6daeb9db060552d851801cd7a0afd68769e2f98b" - integrity sha512-7WW5SD0XVrpfqljBYzS5rLR+EiDzl7wCVJZ9Lo6ChNFV4VYDk37Z1QI5w/LnYtU/QKnSawYoHRd7VjSyC8QRQQ== +"@smithy/util-middleware@^3.0.2", "@smithy/util-middleware@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-3.0.8.tgz#372bc7a2845408ad69da039d277fc23c2734d0c6" + integrity sha512-p7iYAPaQjoeM+AKABpYWeDdtwQNxasr4aXQEA/OmbOaug9V0odRVDy3Wx4ci8soljE/JXQo+abV0qZpW8NX0yA== dependencies: - "@smithy/types" "^3.2.0" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/util-retry@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-3.0.8.tgz#9c607c175a4d8a87b5d8ebaf308f6b849e4dc4d0" + integrity sha512-TCEhLnY581YJ+g1x0hapPz13JFqzmh/pMWL2KEFASC51qCfw3+Y47MrTmea4bUE5vsdxQ4F6/KFbUeSz22Q1ow== + dependencies: + "@smithy/service-error-classification" "^3.0.8" + "@smithy/types" "^3.6.0" + tslib "^2.6.2" + +"@smithy/util-stream@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-3.2.1.tgz#f3055dc4c8caba8af4e47191ea7e773d0e5a429d" + integrity sha512-R3ufuzJRxSJbE58K9AEnL/uSZyVdHzud9wLS8tIbXclxKzoe09CRohj2xV8wpx5tj7ZbiJaKYcutMm1eYgz/0A== + dependencies: + "@smithy/fetch-http-handler" "^4.0.0" + "@smithy/node-http-handler" "^3.2.5" + "@smithy/types" "^3.6.0" + "@smithy/util-base64" "^3.0.0" + "@smithy/util-buffer-from" "^3.0.0" + "@smithy/util-hex-encoding" "^3.0.0" + "@smithy/util-utf8" "^3.0.0" tslib "^2.6.2" "@smithy/util-uri-escape@^3.0.0": @@ -11584,6 +12448,11 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2" integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q== +"@types/uuid@^9.0.1": + version "9.0.8" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" + integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== + "@types/vinyl-fs@*", "@types/vinyl-fs@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-3.0.2.tgz#cbaef5160ad7695483af0aa1b4fe67f166c18feb" @@ -13567,6 +14436,11 @@ bowser@^1.7.3: resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== +bowser@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== + boxen@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" @@ -17938,6 +18812,13 @@ fast-text-encoding@^1.0.0: resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== +fast-xml-parser@4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" + integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== + dependencies: + strnum "^1.0.5" + fastest-levenshtein@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2" @@ -29457,6 +30338,11 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strnum@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" + integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + style-loader@^1.1.3, style-loader@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.3.0.tgz#828b4a3b3b7e7aa5847ce7bae9e874512114249e" From 080d0ff97f00bf564dedfd8fd37cdac0370e1349 Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Tue, 19 Nov 2024 22:23:52 +0100 Subject: [PATCH 54/61] [EEM] Add built in definitions for core Kubernetes entities (#196916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🍒 Summary This PR adds the OTEL and ECS entity definition for Kubernetes. This covers the following datasets: - Cluster - Service (ECS Only) - Pod - ReplicaSet - Deployment - Statefulset - DaemonSet - Job - CronJob - Node This PR does not include Container per @roshan-elastic ### ✅ TODO - [X] Use correct index pattern for SemConv data (`metrics-k8sclusterreceiver.otel-default`, `metrics-kubeletstatsreceiver.otel-default`) Use global IDs instead of local IDs - [X] Add minimal list of labels to track beyond what was already added (wildcards are not supported, example `container.image.name` for containers to allow to find all "redis" containers) - [ ] Test with ECS data, SemConv data and mixed data (to check if we get duplicates, with the container definition for example). ### 🐴 Follow up EEM features https://github.com/elastic/elastic-entity-model/issues/170 (Add dedicated aggregation for display name and use that instead to provide a better label than the global ID) https://github.com/elastic/elastic-entity-model/issues/193 (Add entity type display label to allow UI to not hard code a user friendly label) --------- Co-authored-by: Chris Cowan Co-authored-by: Elastic Machine --- .../server/lib/entities/built_in/index.ts | 3 ++ .../kubernetes/common/ecs_index_patterns.ts | 8 ++++ .../kubernetes/common/ecs_metadata.ts | 28 +++++++++++ .../kubernetes/common/global_metadata.ts | 26 +++++++++++ .../kubernetes/common/otel_index_patterns.ts | 8 ++++ .../kubernetes/common/otel_metadata.ts | 23 ++++++++++ .../built_in/kubernetes/ecs/cluster.ts | 46 +++++++++++++++++++ .../built_in/kubernetes/ecs/cron_job.ts | 34 ++++++++++++++ .../built_in/kubernetes/ecs/daemon_set.ts | 34 ++++++++++++++ .../built_in/kubernetes/ecs/deployment.ts | 34 ++++++++++++++ .../entities/built_in/kubernetes/ecs/index.ts | 17 +++++++ .../entities/built_in/kubernetes/ecs/job.ts | 34 ++++++++++++++ .../entities/built_in/kubernetes/ecs/node.ts | 34 ++++++++++++++ .../entities/built_in/kubernetes/ecs/pod.ts | 34 ++++++++++++++ .../built_in/kubernetes/ecs/replica_set.ts | 33 +++++++++++++ .../built_in/kubernetes/ecs/service.ts | 34 ++++++++++++++ .../built_in/kubernetes/ecs/stateful_set.ts | 34 ++++++++++++++ .../lib/entities/built_in/kubernetes/index.ts | 9 ++++ .../built_in/kubernetes/semconv/cluster.ts | 34 ++++++++++++++ .../built_in/kubernetes/semconv/cron_job.ts | 34 ++++++++++++++ .../built_in/kubernetes/semconv/daemon_set.ts | 34 ++++++++++++++ .../built_in/kubernetes/semconv/deployment.ts | 34 ++++++++++++++ .../built_in/kubernetes/semconv/index.ts | 16 +++++++ .../built_in/kubernetes/semconv/job.ts | 34 ++++++++++++++ .../built_in/kubernetes/semconv/node.ts | 34 ++++++++++++++ .../built_in/kubernetes/semconv/pod.ts | 34 ++++++++++++++ .../kubernetes/semconv/replica_set.ts | 34 ++++++++++++++ .../kubernetes/semconv/stateful_set.ts | 34 ++++++++++++++ .../entities/uninstall_entity_definition.ts | 5 +- .../apis/entity_manager/helpers/request.ts | 6 +-- 30 files changed, 802 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_index_patterns.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_metadata.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/global_metadata.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_index_patterns.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_metadata.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cluster.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cron_job.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/daemon_set.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/deployment.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/index.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/job.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/node.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/pod.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/replica_set.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/service.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/stateful_set.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/index.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cluster.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cron_job.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/daemon_set.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/deployment.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/index.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/job.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/node.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/pod.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/replica_set.ts create mode 100644 x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/stateful_set.ts diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/index.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/index.ts index 6c0d4c5995c63..6b1c384f5b541 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/built_in/index.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/index.ts @@ -10,10 +10,13 @@ import { builtInServicesFromEcsEntityDefinition } from './services_from_ecs_data import { builtInHostsFromEcsEntityDefinition } from './hosts_from_ecs_data'; import { builtInContainersFromEcsEntityDefinition } from './containers_from_ecs_data'; +import * as kubernetes from './kubernetes'; + export { BUILT_IN_ID_PREFIX } from './constants'; export const builtInDefinitions: EntityDefinition[] = [ builtInServicesFromEcsEntityDefinition, builtInHostsFromEcsEntityDefinition, builtInContainersFromEcsEntityDefinition, + ...Object.values(kubernetes), ]; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_index_patterns.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_index_patterns.ts new file mode 100644 index 0000000000000..14e2766cac2b2 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_index_patterns.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 const commonEcsIndexPatterns = ['metrics-kubernetes*', 'logs-*']; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_metadata.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_metadata.ts new file mode 100644 index 0000000000000..5995b4aa46d5f --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/ecs_metadata.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 { MetadataField } from '@kbn/entities-schema'; +import { globalMetadata } from './global_metadata'; + +export const commonEcsMetadata: MetadataField[] = [ + ...globalMetadata, + { + source: 'orchestrator.namespace', + destination: 'orchestrator.namespace', + aggregation: { type: 'terms', limit: 10 }, + }, + { + source: 'orchestrator.cluster_ip', + destination: 'orchestrator.cluster_id', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, + { + source: 'orchestrator.cluster_name', + destination: 'orchestrator.cluster_name', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, +]; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/global_metadata.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/global_metadata.ts new file mode 100644 index 0000000000000..bc7c8fc03a930 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/global_metadata.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 { MetadataField } from '@kbn/entities-schema'; + +export const globalMetadata: MetadataField[] = [ + { + source: '_index', + destination: 'source_index', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, + { + source: 'data_stream.type', + destination: 'source_data_stream.type', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, + { + source: 'data_stream.dataset', + destination: 'source_data_stream.dataset', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, +]; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_index_patterns.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_index_patterns.ts new file mode 100644 index 0000000000000..9978f61efafab --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_index_patterns.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 const commonOtelIndexPatterns = ['metrics-*otel*', 'logs-*']; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_metadata.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_metadata.ts new file mode 100644 index 0000000000000..946f5cc4ead43 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/common/otel_metadata.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 { MetadataField } from '@kbn/entities-schema'; +import { globalMetadata } from './global_metadata'; + +export const commonOtelMetadata: MetadataField[] = [ + ...globalMetadata, + { + source: 'k8s.namespace.name', + destination: 'k8s.namespace.name', + aggregation: { type: 'terms', limit: 10 }, + }, + { + source: 'k8s.cluster.name', + destination: 'k8s.cluster.name', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, +]; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cluster.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cluster.ts new file mode 100644 index 0000000000000..1d452b8a8620e --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cluster.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; +import { globalMetadata } from '../common/global_metadata'; + +export const builtInKubernetesClusterEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_cluster_ecs`, + filter: 'orchestrator.cluster.name: *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Clusters from ECS data', + description: + 'This definition extracts Kubernetes cluster entities from the Kubernetes integration data streams', + type: 'k8s.cluster.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['orchestrator.cluster.name'], + displayNameTemplate: '{{orchestrator.cluster.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: [ + ...globalMetadata, + { + source: 'orchestrator.namespace', + destination: 'orchestrator.namespace', + aggregation: { type: 'terms', limit: 10 }, + }, + { + source: 'orchestrator.cluster_ip', + destination: 'orchestrator.cluster_id', + aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, + }, + ], + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cron_job.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cron_job.ts new file mode 100644 index 0000000000000..7849dcdc73f5b --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/cron_job.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; +import { commonEcsMetadata } from '../common/ecs_metadata'; + +export const builtInKubernetesCronJobEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_cron_job_ecs`, + filter: 'kubernetes.cronjob.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes CronJob from ECS data', + description: + 'This definition extracts Kubernetes cron job entities from the Kubernetes integration data streams', + type: 'k8s.cronjob.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.cronjob.uid'], + displayNameTemplate: '{{kubernetes.cronjob.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/daemon_set.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/daemon_set.ts new file mode 100644 index 0000000000000..5b57cdd6ae2f8 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/daemon_set.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; +import { commonEcsMetadata } from '../common/ecs_metadata'; + +export const builtInKubernetesDaemonSetEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_daemon_set_ecs`, + filter: 'kubernetes.daemonset.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes DaemonSet from ECS data', + description: + 'This definition extracts Kubernetes daemon set entities from the Kubernetes integration data streams', + type: 'k8s.daemonset.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.daemonset.name'], + displayNameTemplate: '{{kubernetes.daemonset.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/deployment.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/deployment.ts new file mode 100644 index 0000000000000..d33c14db7e2c9 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/deployment.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsMetadata } from '../common/ecs_metadata'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; + +export const builtInKubernetesDeploymentEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_deployment_ecs`, + filter: 'kubernetes.deployment.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Deployment from ECS data', + description: + 'This definition extracts Kubernetes deployment entities from the Kubernetes integration data streams', + type: 'k8s.deployment.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.deployment.uid'], + displayNameTemplate: '{{kubernetes.deployment.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/index.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/index.ts new file mode 100644 index 0000000000000..ecfa67ff893ba --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/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. + */ + +export { builtInKubernetesClusterEcsEntityDefinition } from './cluster'; +export { builtInKubernetesNodeEcsEntityDefinition } from './node'; +export { builtInKubernetesPodEcsEntityDefinition } from './pod'; +export { builtInKubernetesReplicaSetEcsEntityDefinition } from './replica_set'; +export { builtInKubernetesDeploymentEcsEntityDefinition } from './deployment'; +export { builtInKubernetesStatefulSetEcsEntityDefinition } from './stateful_set'; +export { builtInKubernetesDaemonSetEcsEntityDefinition } from './daemon_set'; +export { builtInKubernetesJobEcsEntityDefinition } from './job'; +export { builtInKubernetesCronJobEcsEntityDefinition } from './cron_job'; +export { builtInKubernetesServiceEcsEntityDefinition } from './service'; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/job.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/job.ts new file mode 100644 index 0000000000000..92c6d13251553 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/job.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; +import { commonEcsMetadata } from '../common/ecs_metadata'; + +export const builtInKubernetesJobEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_job_ecs`, + filter: 'kubernetes.job.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Job from ECS data', + description: + 'This definition extracts Kubernetes job entities from the Kubernetes integration data streams', + type: 'k8s.job.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.job.uid'], + displayNameTemplate: '{{kubernetes.job.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/node.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/node.ts new file mode 100644 index 0000000000000..f3fdcdfaf04b4 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/node.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; +import { commonEcsMetadata } from '../common/ecs_metadata'; + +export const builtInKubernetesNodeEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_node_ecs`, + filer: 'kubernetes.node.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Node from ECS data', + description: + 'This definition extracts Kubernetes node entities from the Kubernetes integration data streams', + type: 'k8s.node.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.node.uid'], + displayNameTemplate: '{{kubernetes.node.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/pod.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/pod.ts new file mode 100644 index 0000000000000..7aa53da6e5a7d --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/pod.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsMetadata } from '../common/ecs_metadata'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; + +export const builtInKubernetesPodEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_pod_ecs`, + filter: 'kubernetes.pod.uid: *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Pod from ECS data', + description: + 'This definition extracts Kubernetes pod entities from the Kubernetes integration data streams', + type: 'k8s.pod.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.pod.name'], + displayNameTemplate: '{{kubernetes.pod.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/replica_set.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/replica_set.ts new file mode 100644 index 0000000000000..cc059c14979d0 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/replica_set.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsMetadata } from '../common/ecs_metadata'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; + +export const builtInKubernetesReplicaSetEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_replica_set_ecs`, + managed: true, + version: '0.1.0', + name: 'Kubernetes ReplicaSet from ECS data', + description: + 'This definition extracts Kubernetes replica set entities from the Kubernetes integration data streams', + type: 'k8s.replicaset.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.replicaset.uid'], + displayNameTemplate: '{{kubernetes.replicaset.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/service.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/service.ts new file mode 100644 index 0000000000000..be1b3b7c6b1c4 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/service.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsMetadata } from '../common/ecs_metadata'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; + +export const builtInKubernetesServiceEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_service_ecs`, + filter: 'kubernetes.service.name: *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Services from ECS data', + description: + 'This definition extracts Kubernetes service entities from the Kubernetes integration data streams', + type: 'k8s.service.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.service.name'], + displayNameTemplate: '{{kubernetes.service.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/stateful_set.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/stateful_set.ts new file mode 100644 index 0000000000000..79f9d4489216f --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/ecs/stateful_set.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonEcsMetadata } from '../common/ecs_metadata'; +import { commonEcsIndexPatterns } from '../common/ecs_index_patterns'; + +export const builtInKubernetesStatefulSetEcsEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_stateful_set_ecs`, + filter: 'kubernetes.statefulset.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes StatefulSet from ECS data', + description: + 'This definition extracts Kubernetes stateful set entities from the Kubernetes integration data streams', + type: 'k8s.statefulset.ecs', + indexPatterns: commonEcsIndexPatterns, + identityFields: ['kubernetes.statefulset.uid'], + displayNameTemplate: '{{kubernetes.statefulset.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonEcsMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/index.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/index.ts new file mode 100644 index 0000000000000..fa559fb86d9db --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './ecs'; +export * from './semconv'; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cluster.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cluster.ts new file mode 100644 index 0000000000000..0ec244ec617f3 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cluster.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; +import { commonOtelMetadata } from '../common/otel_metadata'; + +export const builtInKubernetesClusterSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_cluster_semconv`, + filter: 'k8s.cluster.uid: *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Clusters from SemConv data', + description: + 'This definition extracts Kubernetes cluster entities using data collected with OpenTelemetry', + type: 'kubernetes_cluster_semconv', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.cluster.uid'], + displayNameTemplate: '{{k8s.cluster.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cron_job.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cron_job.ts new file mode 100644 index 0000000000000..6d677943976d1 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/cron_job.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; +import { commonOtelMetadata } from '../common/otel_metadata'; + +export const builtInKubernetesCronJobSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_cron_job_semconv`, + filter: 'k8s.cronjob.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes CronJob from SemConv data', + description: + 'This definition extracts Kubernetes cron job entities using data collected with OpenTelemetry', + type: 'k8s.cronjob.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.cronjob.uid'], + displayNameTemplate: '{{k8s.cronjob.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/daemon_set.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/daemon_set.ts new file mode 100644 index 0000000000000..a4b61933ad316 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/daemon_set.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; +import { commonOtelMetadata } from '../common/otel_metadata'; + +export const builtInKubernetesDaemonSetSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_daemon_set_semconv`, + filter: 'k8s.daemonset.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes DaemonSet from SemConv data', + description: + 'This definition extracts Kubernetes daemon set entities using data collected with OpenTelemetry', + type: 'k8s.daemonset.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.daemonset.uid'], + displayNameTemplate: '{{k8s.daemonset.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/deployment.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/deployment.ts new file mode 100644 index 0000000000000..bdb3cb1cef59b --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/deployment.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelMetadata } from '../common/otel_metadata'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; + +export const builtInKubernetesDeploymentSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_deployment_semconv`, + filter: 'k8s.deployment.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Deployment from SemConv data', + description: + 'This definition extracts Kubernetes deployment entities using data collected with OpenTelemetry', + type: 'k8s.deployment.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.deployment.uid'], + displayNameTemplate: '{{k8s.deployment.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/index.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/index.ts new file mode 100644 index 0000000000000..fbfcd9c5f9024 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { builtInKubernetesClusterSemConvEntityDefinition } from './cluster'; +export { builtInKubernetesNodeSemConvEntityDefinition } from './node'; +export { builtInKubernetesPodSemConvEntityDefinition } from './pod'; +export { builtInKubernetesReplicaSetSemConvEntityDefinition } from './replica_set'; +export { builtInKubernetesDeploymentSemConvEntityDefinition } from './deployment'; +export { builtInKubernetesStatefulSetSemConvEntityDefinition } from './stateful_set'; +export { builtInKubernetesDaemonSetSemConvEntityDefinition } from './daemon_set'; +export { builtInKubernetesJobSemConvEntityDefinition } from './job'; +export { builtInKubernetesCronJobSemConvEntityDefinition } from './cron_job'; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/job.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/job.ts new file mode 100644 index 0000000000000..b2e48cf7494fb --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/job.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; +import { commonOtelMetadata } from '../common/otel_metadata'; + +export const builtInKubernetesJobSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_job_semconv`, + filter: 'k8s.job.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Job from SemConv data', + description: + 'This definition extracts Kubernetes job entities using data collected with OpenTelemetry', + type: 'k8s.job.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.job.uid'], + displayNameTemplate: '{{k8s.job.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/node.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/node.ts new file mode 100644 index 0000000000000..456f030421075 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/node.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; +import { commonOtelMetadata } from '../common/otel_metadata'; + +export const builtInKubernetesNodeSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_node_semconv`, + filter: 'k8s.node.uid: *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Node from SemConv data', + description: + 'This definition extracts Kubernetes node entities using data collected with OpenTelemetry', + type: 'k8s.node.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.node.uid'], + displayNameTemplate: '{{k8s.node.uid}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/pod.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/pod.ts new file mode 100644 index 0000000000000..6dc879d761dd8 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/pod.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelMetadata } from '../common/otel_metadata'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; + +export const builtInKubernetesPodSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_pod_semconv`, + filter: 'k8s.pod.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes Pod from SemConv data', + description: + 'This definition extracts Kubernetes pod entities using data collected with OpenTelemetry', + type: 'k8s.pod.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.pod.uid'], + displayNameTemplate: '{{k8s.pod.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/replica_set.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/replica_set.ts new file mode 100644 index 0000000000000..47bad6bf8a641 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/replica_set.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelMetadata } from '../common/otel_metadata'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; + +export const builtInKubernetesReplicaSetSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_replica_set_semconv`, + filter: 'k8s.replicaset.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes ReplicaSet from SemConv data', + description: + 'This definition extracts Kubernetes replica set entities using data collected with OpenTelemetry', + type: 'kubernetes_replica_set_semconv', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.replicaset.name'], + displayNameTemplate: '{{k8s.replicaset.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/stateful_set.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/stateful_set.ts new file mode 100644 index 0000000000000..c61d7e5d965cd --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/kubernetes/semconv/stateful_set.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 { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema'; +import { BUILT_IN_ID_PREFIX } from '../../constants'; +import { commonOtelMetadata } from '../common/otel_metadata'; +import { commonOtelIndexPatterns } from '../common/otel_index_patterns'; + +export const builtInKubernetesStatefulSetSemConvEntityDefinition: EntityDefinition = + entityDefinitionSchema.parse({ + id: `${BUILT_IN_ID_PREFIX}kubernetes_stateful_set_semconv`, + filter: 'k8s.statefulset.uid : *', + managed: true, + version: '0.1.0', + name: 'Kubernetes StatefulSet from SemConv data', + description: + 'This definition extracts Kubernetes stateful set entities using data collected with OpenTelemetry', + type: 'k8s.statefulset.otel', + indexPatterns: commonOtelIndexPatterns, + identityFields: ['k8s.statefulset.uid'], + displayNameTemplate: '{{k8s.statefulset.name}}', + latest: { + timestampField: '@timestamp', + lookbackPeriod: '10m', + settings: { + frequency: '5m', + }, + }, + metadata: commonOtelMetadata, + }); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts index f8e27353082d0..e0d7b0c9eed3d 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts @@ -47,7 +47,10 @@ export async function uninstallBuiltInEntityDefinitions({ entityClient: EntityClient; deleteData?: boolean; }): Promise { - const { definitions } = await entityClient.getEntityDefinitions({ builtIn: true }); + const { definitions } = await entityClient.getEntityDefinitions({ + builtIn: true, + perPage: 1000, + }); await Promise.all( definitions.map(async ({ id }) => { diff --git a/x-pack/test/api_integration/apis/entity_manager/helpers/request.ts b/x-pack/test/api_integration/apis/entity_manager/helpers/request.ts index 8eb99ca1fe371..c21f33cc8793a 100644 --- a/x-pack/test/api_integration/apis/entity_manager/helpers/request.ts +++ b/x-pack/test/api_integration/apis/entity_manager/helpers/request.ts @@ -16,12 +16,12 @@ export interface Auth { export const getInstalledDefinitions = async ( supertest: Agent, - params: { auth?: Auth; id?: string; includeState?: boolean } = {} + params: { auth?: Auth; id?: string; includeState?: boolean; perPage?: number } = {} ): Promise<{ definitions: EntityDefinitionWithState[] }> => { - const { auth, id, includeState = true } = params; + const { auth, id, includeState = true, perPage = 1000 } = params; let req = supertest .get(`/internal/entities/definition${id ? `/${id}` : ''}`) - .query({ includeState }) + .query({ includeState, perPage }) .set('kbn-xsrf', 'xxx'); if (auth) { req = req.auth(auth.username, auth.password); From 69dc8a21f351c1cc26d030c8d3b05fc4109e64cb Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:48:29 -0600 Subject: [PATCH 55/61] Update dependency eslint-plugin-depend to ^0.12.0 (main) (#199593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [eslint-plugin-depend](https://togithub.com/es-tooling/eslint-plugin-depend) | devDependencies | minor | [`^0.11.0` -> `^0.12.0`](https://renovatebot.com/diffs/npm/eslint-plugin-depend/0.11.0/0.12.0) | --- ### Release Notes
es-tooling/eslint-plugin-depend (eslint-plugin-depend) ### [`v0.12.0`](https://togithub.com/es-tooling/eslint-plugin-depend/releases/tag/0.12.0) [Compare Source](https://togithub.com/es-tooling/eslint-plugin-depend/compare/0.11.0...0.12.0) #### What's Changed - chore: bump module-replacements by [@​43081j](https://togithub.com/43081j) in [https://github.com/es-tooling/eslint-plugin-depend/pull/37](https://togithub.com/es-tooling/eslint-plugin-depend/pull/37) **Full Changelog**: https://github.com/es-tooling/eslint-plugin-depend/compare/0.11.0...0.12.0
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Co-authored-by: Brad White --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ea8bd1feda2f9..0929ea1114a16 100644 --- a/package.json +++ b/package.json @@ -1716,7 +1716,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-ban": "^1.6.0", "eslint-plugin-cypress": "^2.15.1", - "eslint-plugin-depend": "^0.11.0", + "eslint-plugin-depend": "^0.12.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-formatjs": "^4.12.2", "eslint-plugin-import": "^2.28.0", diff --git a/yarn.lock b/yarn.lock index bd0a376098b09..ba4637306c3a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18095,10 +18095,10 @@ eslint-plugin-cypress@^2.15.1: dependencies: globals "^13.20.0" -eslint-plugin-depend@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-depend/-/eslint-plugin-depend-0.11.0.tgz#ef82f6d8c6ae924a42c489dd6bd5b9f3f4eeba82" - integrity sha512-IwF06BrcdYoELuFd18sdVHhvDfF23xbr8pG/ONqrwB4gXjJ7281mEDEmACKWyvMY63afph8+2aOLbeuvr9mbdg== +eslint-plugin-depend@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-depend/-/eslint-plugin-depend-0.12.0.tgz#f0417c69640f3e5b3aee602ea227592313d226eb" + integrity sha512-bS5ESnC3eXDJPNv0RKkzRbLO45hRRLR/dleAUdbysXChWz1bAxa4MRh14EtDREn7fZieueqz4L7TfQQbzvdYHA== dependencies: fd-package-json "^1.2.0" module-replacements "^2.1.0" From 9162fdea146adadec05fa50f2cf4461bf19b7892 Mon Sep 17 00:00:00 2001 From: Robert Jaszczurek <92210485+rbrtj@users.noreply.github.com> Date: Tue, 19 Nov 2024 23:23:41 +0100 Subject: [PATCH 56/61] [ML] Migrate influencers list from SCSS to Emotion (#200019) ## Summary Part of: [#140695](https://github.com/elastic/kibana/issues/140695) Migrates SCSS to emotion for Influencers list. | Before | After | | ------------- | ------------- | | ![image](https://github.com/user-attachments/assets/1f85b2d1-5526-49b2-819d-525989b9c48d) | ![image](https://github.com/user-attachments/assets/e1e5745e-d00a-4a51-ab93-b29ff71d8aef) | | ![image](https://github.com/user-attachments/assets/f1e8d594-8a9d-4f08-98bb-156e21abd1c6) | ![image](https://github.com/user-attachments/assets/d55a2848-2c28-4b4b-88b2-ed1b98b16430) | --- .../plugins/ml/public/application/_index.scss | 1 - .../components/influencers_list/_index.scss | 1 - .../influencers_list/_influencers_list.scss | 109 ------------------ .../influencers_list/influencers_list.tsx | 27 +++-- .../influencers_list_styles.ts | 90 +++++++++++++++ 5 files changed, 103 insertions(+), 125 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/components/influencers_list/_index.scss delete mode 100644 x-pack/plugins/ml/public/application/components/influencers_list/_influencers_list.scss create mode 100644 x-pack/plugins/ml/public/application/components/influencers_list/influencers_list_styles.ts diff --git a/x-pack/plugins/ml/public/application/_index.scss b/x-pack/plugins/ml/public/application/_index.scss index 029a422afaa9f..91201434b20b1 100644 --- a/x-pack/plugins/ml/public/application/_index.scss +++ b/x-pack/plugins/ml/public/application/_index.scss @@ -11,7 +11,6 @@ @import 'components/annotations/annotation_description_list/index'; // SASSTODO: This file overwrites EUI directly @import 'components/anomalies_table/index'; // SASSTODO: This file overwrites EUI directly @import 'components/entity_cell/index'; - @import 'components/influencers_list/index'; @import 'components/job_selector/index'; @import 'components/rule_editor/index'; // SASSTODO: This file overwrites EUI directly diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/_index.scss b/x-pack/plugins/ml/public/application/components/influencers_list/_index.scss deleted file mode 100644 index 90ff743d162f0..0000000000000 --- a/x-pack/plugins/ml/public/application/components/influencers_list/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'influencers_list'; \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/_influencers_list.scss b/x-pack/plugins/ml/public/application/components/influencers_list/_influencers_list.scss deleted file mode 100644 index 1b091e4046c50..0000000000000 --- a/x-pack/plugins/ml/public/application/components/influencers_list/_influencers_list.scss +++ /dev/null @@ -1,109 +0,0 @@ -.ml-influencers-list { - line-height: 1.45; // SASSTODO: Calc proper value - - .field-label { - font-size: $euiFontSizeXS; - text-align: left; - max-height: $euiFontSizeS; - max-width: calc(100% - 102px); // SASSTODO: Calc proper value - - .field-value { - @include euiTextTruncate; - display: inline-block; - vertical-align: bottom; - } - } - - .progress { - display: inline-block; - width: calc(100% - 34px); // SASSTODO: Calc proper value - height: 22px; - min-width: 70px; - margin-bottom: 0; - color: $euiColorDarkShade; - background-color: transparent; - - .progress-bar-holder { - width: calc(100% - 28px); // SASSTODO: Calc proper value - } - - .progress-bar { - height: calc($euiSizeXS / 2); - margin-top: $euiSizeM; - text-align: right; - line-height: 18px; // SASSTODO: Calc proper value - display: inline-block; - transition: none; - } - } - - // SASSTODO: This range of color is too large, needs to be rewritten and variablized - .progress.critical { - .progress-bar { - background-color: $mlColorCritical; - } - - .score-label { - border-color: $mlColorCritical; - } - } - - .progress.major { - .progress-bar { - background-color: $mlColorMajor; - } - - .score-label { - border-color: $mlColorMajor; - } - } - - .progress.minor { - .progress-bar { - background-color: $mlColorMinor; - } - - .score-label { - border-color: $mlColorMinor; - } - } - - .progress.warning { - .progress-bar { - background-color: $mlColorWarning; - } - - .score-label { - border-color: $mlColorWarning; - } - } - - .score-label { - text-align: center; - line-height: 14px; - white-space: nowrap; - font-size: $euiFontSizeXS; - display: inline; - margin-left: $euiSizeXS; - } - - // SASSTODO: Brittle sizing - .total-score-label { - width: $euiSizeXL; - vertical-align: top; - text-align: center; - color: $euiColorDarkShade; - font-size: 11px; - line-height: 14px; - border-radius: $euiBorderRadius; - padding: calc($euiSizeXS / 2); - margin-top: $euiSizeXS; - display: inline-block; - border: $euiBorderThin; - } -} - -// SASSTODO: Can .eui-textBreakAll -.ml-influencers-list-tooltip { - word-break: break-all; -} diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx index 39556dfe6a0f4..35f3bb83ebb10 100644 --- a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx +++ b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx @@ -19,6 +19,7 @@ import { getSeverity, getFormattedSeverityScore } from '@kbn/ml-anomaly-utils'; import { abbreviateWholeNumber } from '../../formatters/abbreviate_whole_number'; import type { EntityCellFilter } from '../entity_cell'; import { EntityCell } from '../entity_cell'; +import { useInfluencersListStyles } from './influencers_list_styles'; export interface InfluencerValueData { influencerFieldValue: string; @@ -65,6 +66,7 @@ function getTooltipContent(maxScoreLabel: string, totalScoreLabel: string) { } const Influencer: FC = ({ influencerFieldName, influencerFilter, valueData }) => { + const styles = useInfluencersListStyles(); const maxScore = Math.floor(valueData.maxAnomalyScore); const maxScoreLabel = getFormattedSeverityScore(valueData.maxAnomalyScore); const severity = getSeverity(maxScore); @@ -73,29 +75,25 @@ const Influencer: FC = ({ influencerFieldName, influencerFilter // Ensure the bar has some width for 0 scores. const barScore = maxScore !== 0 ? maxScore : 1; - const barStyle = { - width: `${barScore}%`, - }; const tooltipContent = getTooltipContent(maxScoreLabel, totalScoreLabel); return (
-
+
-
-
-
+
+
+
-
+
@@ -103,10 +101,9 @@ const Influencer: FC = ({ influencerFieldName, influencerFilter
-
+
@@ -145,12 +142,14 @@ const InfluencersByName: FC = ({ }; export const InfluencersList: FC = ({ influencers, influencerFilter }) => { + const styles = useInfluencersListStyles(); + if (influencers === undefined || Object.keys(influencers).length === 0) { return ( - + - +

= ({ influencers, influen /> )); - return
{influencersByName}
; + return
{influencersByName}
; }; diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list_styles.ts b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list_styles.ts new file mode 100644 index 0000000000000..5a0732ceb8d70 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list_styles.ts @@ -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 { css } from '@emotion/react'; +import { useCurrentEuiThemeVars } from '@kbn/ml-kibana-theme'; +import { mlColors } from '../../styles'; +import { useMlKibana } from '../../contexts/kibana'; + +export const useInfluencersListStyles = () => { + const { + services: { theme }, + } = useMlKibana(); + const { euiTheme } = useCurrentEuiThemeVars(theme); + + return { + influencersList: css({ + lineHeight: 1.45, + }), + fieldLabel: css({ + fontSize: euiTheme.euiFontSizeXS, + textAlign: 'left', + maxHeight: euiTheme.euiFontSizeS, + maxWidth: 'calc(100% - 102px)', + }), + progress: css({ + display: 'inline-block', + width: 'calc(100% - 34px)', + height: '22px', + minWidth: '70px', + marginBottom: 0, + color: euiTheme.euiColorDarkShade, + backgroundColor: 'transparent', + }), + progressBarHolder: css({ + width: `calc(100% - 28px)`, + }), + progressBar: (severity: string, barScore: number) => + css({ + height: `calc(${euiTheme.euiSizeXS} / 2)`, + float: 'left', + marginTop: euiTheme.euiSizeM, + textAlign: 'right', + lineHeight: '18px', + display: 'inline-block', + transition: 'none', + width: `${barScore}%`, + backgroundColor: + severity === 'critical' + ? mlColors.critical + : severity === 'major' + ? mlColors.major + : severity === 'minor' + ? mlColors.minor + : mlColors.warning, + }), + scoreLabel: (severity: string) => + css({ + textAlign: 'center', + lineHeight: '14px', + whiteSpace: 'nowrap', + fontSize: euiTheme.euiFontSizeXS, + marginLeft: euiTheme.euiSizeXS, + display: 'inline', + borderColor: + severity === 'critical' + ? mlColors.critical + : severity === 'major' + ? mlColors.major + : severity === 'minor' + ? mlColors.minor + : mlColors.warning, + }), + totalScoreLabel: css({ + width: euiTheme.euiSizeXL, + verticalAlign: 'top', + textAlign: 'center', + color: euiTheme.euiColorDarkShade, + fontSize: '11px', + lineHeight: '14px', + borderRadius: euiTheme.euiBorderRadius, + padding: `calc(${euiTheme.euiSizeXS} / 2)`, + display: 'inline-block', + border: euiTheme.euiBorderThin, + }), + }; +}; From 78f4d75c3b098442411e6db70d845e7ee4409014 Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:32:36 -0600 Subject: [PATCH 57/61] Update dependency terser to ^5.36.0 (main) (#197476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [terser](https://terser.org) ([source](https://togithub.com/terser/terser)) | devDependencies | minor | [`^5.34.0` -> `^5.36.0`](https://renovatebot.com/diffs/npm/terser/5.34.1/5.36.0) | --- ### Release Notes
terser/terser (terser) ### [`v5.36.0`](https://togithub.com/terser/terser/blob/HEAD/CHANGELOG.md#v5360) [Compare Source](https://togithub.com/terser/terser/compare/v5.35.0...v5.36.0) - Support import attributes `with` syntax ### [`v5.35.0`](https://togithub.com/terser/terser/blob/HEAD/CHANGELOG.md#v5350) [Compare Source](https://togithub.com/terser/terser/compare/v5.34.1...v5.35.0) - Ensure parent directory exists when using --output on CLI ([#​1530](https://togithub.com/terser/terser/issues/1530))
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). --------- Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Co-authored-by: Brad White Co-authored-by: Brad White --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0929ea1114a16..c93eead578c2f 100644 --- a/package.json +++ b/package.json @@ -1832,7 +1832,7 @@ "swagger-ui-express": "^5.0.1", "table": "^6.8.1", "tape": "^5.0.1", - "terser": "^5.34.0", + "terser": "^5.36.0", "terser-webpack-plugin": "^4.2.3", "tough-cookie": "^5.0.0", "trace": "^3.2.0", diff --git a/yarn.lock b/yarn.lock index ba4637306c3a0..9bf235438f586 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30907,10 +30907,10 @@ terser@^4.1.2, terser@^4.6.3: source-map "~0.6.1" source-map-support "~0.5.12" -terser@^5.26.0, terser@^5.3.4, terser@^5.34.0, terser@^5.9.0: - version "5.34.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.34.1.tgz#af40386bdbe54af0d063e0670afd55c3105abeb6" - integrity sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA== +terser@^5.26.0, terser@^5.3.4, terser@^5.36.0, terser@^5.9.0: + version "5.36.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.36.0.tgz#8b0dbed459ac40ff7b4c9fd5a3a2029de105180e" + integrity sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" From 158a828a8e4cf78390fa8711666609c8e16a71ca Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Tue, 19 Nov 2024 18:42:09 -0500 Subject: [PATCH 58/61] [CLOUD-UI] Cloud onboarding token (#198444) ## Summary The solution must be aware of the onboarding token from the cloud onboarding flow. With this information, it can redirect our users to the appropriate onboarding flow in Kibana based on their token. We need to create an API in kibana for cloud to save some basic data. ### 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 --------- Co-authored-by: Christiane (Tina) Heiligers Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/ftr_platform_stateful_configs.yml | 1 + .../current_fields.json | 1 + .../current_mappings.json | 4 + .../check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + x-pack/plugins/cloud/server/plugin.ts | 14 ++- .../plugins/cloud/server/routes/constants.ts | 8 ++ ...earch_routes.ts => elasticsearch_route.ts} | 2 +- .../server/routes/get_cloud_data_route.ts | 43 +++++++ x-pack/plugins/cloud/server/routes/index.ts | 27 ++++ .../routes/set_cloud_data_route.test.ts | 119 ++++++++++++++++++ .../server/routes/set_cloud_data_route.ts | 92 ++++++++++++++ x-pack/plugins/cloud/server/routes/types.ts | 20 +++ .../cloud/server/saved_objects/index.ts | 27 ++++ .../cloud_data_model_versions.ts | 19 +++ .../saved_objects/model_versions/index.ts | 8 ++ x-pack/plugins/cloud/tsconfig.json | 1 + .../test/api_integration/apis/cloud/config.ts | 26 ++++ .../test/api_integration/apis/cloud/index.ts | 14 +++ .../apis/cloud/set_cloud_data_route.ts | 41 ++++++ 20 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/cloud/server/routes/constants.ts rename x-pack/plugins/cloud/server/routes/{elasticsearch_routes.ts => elasticsearch_route.ts} (96%) create mode 100644 x-pack/plugins/cloud/server/routes/get_cloud_data_route.ts create mode 100644 x-pack/plugins/cloud/server/routes/index.ts create mode 100644 x-pack/plugins/cloud/server/routes/set_cloud_data_route.test.ts create mode 100644 x-pack/plugins/cloud/server/routes/set_cloud_data_route.ts create mode 100644 x-pack/plugins/cloud/server/routes/types.ts create mode 100644 x-pack/plugins/cloud/server/saved_objects/index.ts create mode 100644 x-pack/plugins/cloud/server/saved_objects/model_versions/cloud_data_model_versions.ts create mode 100644 x-pack/plugins/cloud/server/saved_objects/model_versions/index.ts create mode 100644 x-pack/test/api_integration/apis/cloud/config.ts create mode 100644 x-pack/test/api_integration/apis/cloud/index.ts create mode 100644 x-pack/test/api_integration/apis/cloud/set_cloud_data_route.ts diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index 3db1d194e59aa..f55fc2f7b4898 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -375,3 +375,4 @@ enabled: - x-pack/test/custom_branding/config.ts # stateful config files that run deployment-agnostic tests - x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.stateful.config.ts + - x-pack/test/api_integration/apis/cloud/config.ts diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 020b9a97753b4..619bdd6c29321 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -233,6 +233,7 @@ "payload.connector.type", "type" ], + "cloud": [], "cloud-security-posture-settings": [], "config": [ "buildNum" diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 2409b7578da84..d6ec30393e099 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -788,6 +788,10 @@ } } }, + "cloud": { + "dynamic": false, + "properties": {} + }, "cloud-security-posture-settings": { "dynamic": false, "properties": {} diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 0f186fba94b54..f16c956107c7b 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -80,6 +80,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cases-rules": "6d1776f5c46a99e1a0f3085c537146c1cdfbc829", "cases-telemetry": "f219eb7e26772884342487fc9602cfea07b3cedc", "cases-user-actions": "483f10db9b3bd1617948d7032a98b7791bf87414", + "cloud": "b549f4f7ab1fd41aab366a66afa52a2a008aefea", "cloud-security-posture-settings": "e0f61c68bbb5e4cfa46ce8994fa001e417df51ca", "config": "179b3e2bc672626aafce3cf92093a113f456af38", "config-global": "8e8a134a2952df700d7d4ec51abb794bbd4cf6da", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index 3ceba522d08cb..cee7f307ee67d 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -32,6 +32,7 @@ const previouslyRegisteredTypes = [ 'canvas-element', 'canvas-workpad', 'canvas-workpad-template', + 'cloud', 'cloud-security-posture-settings', 'cases', 'cases-comments', diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 9821aa318e264..8b20906c30f89 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -9,6 +9,7 @@ import type { Logger } from '@kbn/logging'; import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { SolutionId } from '@kbn/core-chrome-browser'; + import { registerCloudDeploymentMetadataAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import type { CloudConfigType } from './config'; import { registerCloudUsageCollector } from './collectors'; @@ -18,7 +19,9 @@ import { decodeCloudId, DecodedCloudId } from '../common/decode_cloud_id'; import { parseOnboardingSolution } from '../common/parse_onboarding_default_solution'; import { getFullCloudUrl } from '../common/utils'; import { readInstanceSizeMb } from './env'; -import { defineRoutes } from './routes/elasticsearch_routes'; +import { defineRoutes } from './routes'; +import { CloudRequestHandlerContext } from './routes/types'; +import { setupSavedObjects } from './saved_objects'; interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -202,10 +205,15 @@ export class CloudPlugin implements Plugin { if (this.config.id) { decodedId = decodeCloudId(this.config.id, this.logger); } - const router = core.http.createRouter(); + const router = core.http.createRouter(); const elasticsearchUrl = core.elasticsearch.publicBaseUrl || decodedId?.elasticsearchUrl; - defineRoutes({ logger: this.logger, router, elasticsearchUrl }); + defineRoutes({ + logger: this.logger, + router, + elasticsearchUrl, + }); + setupSavedObjects(core.savedObjects, this.logger); return { ...this.getCloudUrls(), cloudId: this.config.id, diff --git a/x-pack/plugins/cloud/server/routes/constants.ts b/x-pack/plugins/cloud/server/routes/constants.ts new file mode 100644 index 0000000000000..a1bfb699ac6b1 --- /dev/null +++ b/x-pack/plugins/cloud/server/routes/constants.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 const CLOUD_DATA_SAVED_OBJECT_ID = 'cloud-data-saved-object-id'; diff --git a/x-pack/plugins/cloud/server/routes/elasticsearch_routes.ts b/x-pack/plugins/cloud/server/routes/elasticsearch_route.ts similarity index 96% rename from x-pack/plugins/cloud/server/routes/elasticsearch_routes.ts rename to x-pack/plugins/cloud/server/routes/elasticsearch_route.ts index d3a5c4bebf305..41537a6dc075b 100644 --- a/x-pack/plugins/cloud/server/routes/elasticsearch_routes.ts +++ b/x-pack/plugins/cloud/server/routes/elasticsearch_route.ts @@ -10,7 +10,7 @@ import { Logger } from '@kbn/logging'; import { ElasticsearchConfigType } from '../../common/types'; import { ELASTICSEARCH_CONFIG_ROUTE } from '../../common/constants'; -export function defineRoutes({ +export function setElasticsearchRoute({ elasticsearchUrl, logger, router, diff --git a/x-pack/plugins/cloud/server/routes/get_cloud_data_route.ts b/x-pack/plugins/cloud/server/routes/get_cloud_data_route.ts new file mode 100644 index 0000000000000..c905e4b641c0c --- /dev/null +++ b/x-pack/plugins/cloud/server/routes/get_cloud_data_route.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RouteOptions } from '.'; +import { CLOUD_DATA_SAVED_OBJECT_ID } from './constants'; +import { CLOUD_DATA_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { CloudDataAttributes } from './types'; + +export const setGetCloudSolutionDataRoute = ({ router }: RouteOptions) => { + router.versioned + .get({ + path: `/internal/cloud/solution`, + access: 'internal', + summary: 'Get cloud data for solutions', + }) + .addVersion( + { + version: '1', + validate: { + request: {}, + }, + }, + async (context, request, response) => { + const coreContext = await context.core; + const savedObjectsClient = coreContext.savedObjects.getClient({ + includedHiddenTypes: [CLOUD_DATA_SAVED_OBJECT_TYPE], + }); + try { + const cloudDataSo = await savedObjectsClient.get( + CLOUD_DATA_SAVED_OBJECT_TYPE, + CLOUD_DATA_SAVED_OBJECT_ID + ); + return response.ok({ body: cloudDataSo?.attributes ?? null }); + } catch (error) { + return response.customError(error); + } + } + ); +}; diff --git a/x-pack/plugins/cloud/server/routes/index.ts b/x-pack/plugins/cloud/server/routes/index.ts new file mode 100644 index 0000000000000..5db24b880881c --- /dev/null +++ b/x-pack/plugins/cloud/server/routes/index.ts @@ -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 { IRouter } from '@kbn/core/server'; +import { Logger } from '@kbn/logging'; +import { setPostCloudSolutionDataRoute } from './set_cloud_data_route'; +import { CloudRequestHandlerContext } from './types'; +import { setElasticsearchRoute } from './elasticsearch_route'; +import { setGetCloudSolutionDataRoute } from './get_cloud_data_route'; + +export interface RouteOptions { + logger: Logger; + router: IRouter; + elasticsearchUrl?: string; +} + +export function defineRoutes(opts: RouteOptions) { + const { logger, elasticsearchUrl, router } = opts; + + setElasticsearchRoute({ logger, elasticsearchUrl, router }); + setGetCloudSolutionDataRoute({ logger, router }); + setPostCloudSolutionDataRoute({ logger, router }); +} diff --git a/x-pack/plugins/cloud/server/routes/set_cloud_data_route.test.ts b/x-pack/plugins/cloud/server/routes/set_cloud_data_route.test.ts new file mode 100644 index 0000000000000..c36e49206a287 --- /dev/null +++ b/x-pack/plugins/cloud/server/routes/set_cloud_data_route.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServerMock, httpServiceMock } from '@kbn/core/server/mocks'; +import { + RequestHandlerContext, + RouteValidatorConfig, + SavedObjectsErrorHelpers, + kibanaResponseFactory, +} from '@kbn/core/server'; +import { CLOUD_DATA_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { CLOUD_DATA_SAVED_OBJECT_ID } from './constants'; +import { setPostCloudSolutionDataRoute } from './set_cloud_data_route'; +import { RouteOptions } from '.'; + +const mockSavedObjectsClientGet = jest.fn(); +const mockSavedObjectsClientCreate = jest.fn(); +const mockSavedObjectsClientUpdate = jest.fn(); + +const mockRouteContext = { + core: { + savedObjects: { + getClient: () => ({ + get: mockSavedObjectsClientGet, + create: mockSavedObjectsClientCreate, + update: mockSavedObjectsClientUpdate, + }), + }, + }, +} as unknown as RequestHandlerContext; + +describe('POST /internal/cloud/solution', () => { + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + + setPostCloudSolutionDataRoute({ + router, + } as unknown as RouteOptions); + + const [routeDefinition, routeHandler] = + router.versioned.post.mock.results[0].value.addVersion.mock.calls[0]; + + return { + routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler, + }; + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create cloud data if it does not exist', async () => { + const { routeHandler } = await setup(); + + mockSavedObjectsClientGet.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError() + ); + + const request = httpServerMock.createKibanaRequest({ + body: { + onboardingData: { + solutionType: 'security', + token: 'test-token', + }, + }, + method: 'post', + }); + + await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(mockSavedObjectsClientGet).toHaveBeenCalledWith( + CLOUD_DATA_SAVED_OBJECT_TYPE, + CLOUD_DATA_SAVED_OBJECT_ID + ); + expect(mockSavedObjectsClientCreate).toHaveBeenCalledWith( + CLOUD_DATA_SAVED_OBJECT_TYPE, + { onboardingData: request.body.onboardingData }, + { id: CLOUD_DATA_SAVED_OBJECT_ID } + ); + }); + + it('should update cloud data if it exists', async () => { + const { routeHandler } = await setup(); + + mockSavedObjectsClientGet.mockResolvedValue({ + id: CLOUD_DATA_SAVED_OBJECT_ID, + attributes: { + onboardingData: { solutionType: 'o11y', token: 'test-33' }, + }, + }); + + const request = httpServerMock.createKibanaRequest({ + body: { + onboardingData: { + solutionType: 'security', + token: 'test-token', + }, + }, + method: 'post', + }); + + await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(mockSavedObjectsClientGet).toHaveBeenCalledWith( + CLOUD_DATA_SAVED_OBJECT_TYPE, + CLOUD_DATA_SAVED_OBJECT_ID + ); + expect(mockSavedObjectsClientUpdate).toHaveBeenCalledWith( + CLOUD_DATA_SAVED_OBJECT_TYPE, + CLOUD_DATA_SAVED_OBJECT_ID, + { onboardingData: request.body.onboardingData } + ); + }); +}); diff --git a/x-pack/plugins/cloud/server/routes/set_cloud_data_route.ts b/x-pack/plugins/cloud/server/routes/set_cloud_data_route.ts new file mode 100644 index 0000000000000..511c8dc2081f0 --- /dev/null +++ b/x-pack/plugins/cloud/server/routes/set_cloud_data_route.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 { schema } from '@kbn/config-schema'; +import { ReservedPrivilegesSet, SavedObjectsErrorHelpers } from '@kbn/core/server'; +import { RouteOptions } from '.'; +import { CLOUD_DATA_SAVED_OBJECT_ID } from './constants'; +import { CLOUD_DATA_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { CloudDataAttributes } from './types'; + +const createBodySchemaV1 = schema.object({ + onboardingData: schema.object({ + solutionType: schema.oneOf([ + schema.literal('security'), + schema.literal('observability'), + schema.literal('search'), + schema.literal('elasticsearch'), + ]), + token: schema.string(), + }), +}); + +export const setPostCloudSolutionDataRoute = ({ router }: RouteOptions) => { + router.versioned + .post({ + path: `/internal/cloud/solution`, + access: 'internal', + summary: 'Save cloud data for solutions', + security: { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.superuser], + }, + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: createBodySchemaV1, + }, + }, + }, + async (context, request, response) => { + const coreContext = await context.core; + const savedObjectsClient = coreContext.savedObjects.getClient({ + includedHiddenTypes: [CLOUD_DATA_SAVED_OBJECT_TYPE], + }); + let cloudDataSo = null; + try { + cloudDataSo = await savedObjectsClient.get( + CLOUD_DATA_SAVED_OBJECT_TYPE, + CLOUD_DATA_SAVED_OBJECT_ID + ); + } catch (error) { + if (SavedObjectsErrorHelpers.isNotFoundError(error)) { + cloudDataSo = null; + } else { + return response.customError(error); + } + } + + try { + if (cloudDataSo === null) { + await savedObjectsClient.create( + CLOUD_DATA_SAVED_OBJECT_TYPE, + { + onboardingData: request.body.onboardingData, + }, + { id: CLOUD_DATA_SAVED_OBJECT_ID } + ); + } else { + await savedObjectsClient.update( + CLOUD_DATA_SAVED_OBJECT_TYPE, + CLOUD_DATA_SAVED_OBJECT_ID, + { + onboardingData: request.body.onboardingData, + } + ); + } + } catch (error) { + return response.badRequest(error); + } + + return response.ok(); + } + ); +}; diff --git a/x-pack/plugins/cloud/server/routes/types.ts b/x-pack/plugins/cloud/server/routes/types.ts new file mode 100644 index 0000000000000..d69877c7b326e --- /dev/null +++ b/x-pack/plugins/cloud/server/routes/types.ts @@ -0,0 +1,20 @@ +/* + * 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 { CustomRequestHandlerContext } from '@kbn/core/server'; + +/** + * @internal + */ +export type CloudRequestHandlerContext = CustomRequestHandlerContext<{}>; + +export interface CloudDataAttributes { + onboardingData: { + solutionType: 'security' | 'observability' | 'search' | 'elasticsearch'; + token: string; + }; +} diff --git a/x-pack/plugins/cloud/server/saved_objects/index.ts b/x-pack/plugins/cloud/server/saved_objects/index.ts new file mode 100644 index 0000000000000..295e6d81a39fb --- /dev/null +++ b/x-pack/plugins/cloud/server/saved_objects/index.ts @@ -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 { Logger, SavedObjectsServiceSetup } from '@kbn/core/server'; + +export const CLOUD_DATA_SAVED_OBJECT_TYPE = 'cloud' as const; + +export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup, logger: Logger) { + savedObjects.registerType({ + name: CLOUD_DATA_SAVED_OBJECT_TYPE, + hidden: true, + hiddenFromHttpApis: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, + management: { + importableAndExportable: false, + }, + modelVersions: {}, + }); +} diff --git a/x-pack/plugins/cloud/server/saved_objects/model_versions/cloud_data_model_versions.ts b/x-pack/plugins/cloud/server/saved_objects/model_versions/cloud_data_model_versions.ts new file mode 100644 index 0000000000000..051a733d39178 --- /dev/null +++ b/x-pack/plugins/cloud/server/saved_objects/model_versions/cloud_data_model_versions.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 { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { schema } from '@kbn/config-schema'; + +export const cloudDataModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: schema.object({}).extends({}, { unknowns: 'ignore' }), + create: schema.object({}), + }, + }, +}; diff --git a/x-pack/plugins/cloud/server/saved_objects/model_versions/index.ts b/x-pack/plugins/cloud/server/saved_objects/model_versions/index.ts new file mode 100644 index 0000000000000..51e8b5431c547 --- /dev/null +++ b/x-pack/plugins/cloud/server/saved_objects/model_versions/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 { cloudDataModelVersions } from './cloud_data_model_versions'; diff --git a/x-pack/plugins/cloud/tsconfig.json b/x-pack/plugins/cloud/tsconfig.json index dd25064897758..37d0b6f4b4de0 100644 --- a/x-pack/plugins/cloud/tsconfig.json +++ b/x-pack/plugins/cloud/tsconfig.json @@ -17,6 +17,7 @@ "@kbn/config-schema", "@kbn/logging-mocks", "@kbn/logging", + "@kbn/core-saved-objects-server", ], "exclude": [ "target/**/*", diff --git a/x-pack/test/api_integration/apis/cloud/config.ts b/x-pack/test/api_integration/apis/cloud/config.ts new file mode 100644 index 0000000000000..87000e8fc5427 --- /dev/null +++ b/x-pack/test/api_integration/apis/cloud/config.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 { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseIntegrationTestsConfig = await readConfigFile(require.resolve('../../config.ts')); + + return { + ...baseIntegrationTestsConfig.getAll(), + testFiles: [require.resolve('.')], + kbnTestServer: { + ...baseIntegrationTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...baseIntegrationTestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.cloud.id="ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM="', + '--xpack.cloud.base_url="https://cloud.elastic.co"', + '--xpack.spaces.allowSolutionVisibility=true', + ], + }, + }; +} diff --git a/x-pack/test/api_integration/apis/cloud/index.ts b/x-pack/test/api_integration/apis/cloud/index.ts new file mode 100644 index 0000000000000..819a9474e0752 --- /dev/null +++ b/x-pack/test/api_integration/apis/cloud/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. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('cloud data', function () { + loadTestFile(require.resolve('./set_cloud_data_route')); + }); +} diff --git a/x-pack/test/api_integration/apis/cloud/set_cloud_data_route.ts b/x-pack/test/api_integration/apis/cloud/set_cloud_data_route.ts new file mode 100644 index 0000000000000..84331ab4c129d --- /dev/null +++ b/x-pack/test/api_integration/apis/cloud/set_cloud_data_route.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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('POST /internal/cloud/solution', () => { + it('set solution data', async () => { + await supertest + .post('/internal/cloud/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .set('elastic-api-version', '1') + .send({ + onboardingData: { + solutionType: 'search', + token: 'connectors', + }, + }) + .expect(200); + + const { + body: { onboardingData }, + } = await supertest + .get('/internal/cloud/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .set('elastic-api-version', '1') + .expect(200); + + expect(onboardingData).to.eql({ solutionType: 'search', token: 'connectors' }); + }); + }); +} From 50a262692fc55ea513f1781eee9d14a7073f0368 Mon Sep 17 00:00:00 2001 From: Robert Jaszczurek <92210485+rbrtj@users.noreply.github.com> Date: Wed, 20 Nov 2024 01:29:40 +0100 Subject: [PATCH 59/61] [ML] AiOps: Action for adding Log Rate analysis embeddable to a dashboard (#200557) ## Summary Part of: [#197247](https://github.com/elastic/kibana/issues/197247) - Added the ability to add a Log Rate Analysis embeddable to a dashboard https://github.com/user-attachments/assets/37efd83e-9196-434d-a80d-9249623f3222 ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [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 --- .../document_count_content.tsx | 9 +- .../log_categorization/attachments_menu.tsx | 51 ++--- .../log_rate_analysis_attachments_menu.tsx | 176 ++++++++++++++++++ .../log_rate_analysis_content.tsx | 2 + .../application/aiops/log_rate_analysis.tsx | 1 + .../apps/aiops/log_rate_analysis.ts | 18 ++ .../services/aiops/log_rate_analysis_page.ts | 48 +++++ 7 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_attachments_menu.tsx diff --git a/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx b/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx index 4dbf021e3b10b..9f4f6f1deb5bd 100644 --- a/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx +++ b/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx @@ -31,11 +31,13 @@ export interface DocumentCountContentProps { barStyleAccessor?: BarStyleAccessor; baselineAnnotationStyle?: RectAnnotationSpec['style']; deviationAnnotationStyle?: RectAnnotationSpec['style']; + attachmentsMenu?: React.ReactNode; } export const DocumentCountContent: FC = ({ barColorOverride, barHighlightColorOverride, + attachmentsMenu, ...docCountChartProps }) => { const { data, uiSettings, fieldFormats, charts, embeddingOrigin } = useAiopsAppContext(); @@ -64,7 +66,12 @@ export const DocumentCountContent: FC = ({ return ( - + + + + + {attachmentsMenu && {attachmentsMenu}} + (() => { + const panels = useMemo>(() => { return [ { id: 'attachMainPanel', @@ -205,26 +205,33 @@ export const AttachmentsMenu = ({ ]); return ( - - setIsActionMenuOpen(!isActionMenuOpen)} - /> - } - isOpen={isActionMenuOpen} - closePopover={() => setIsActionMenuOpen(false)} - panelPaddingSize="none" - anchorPosition="downRight" - > - - + <> + {!!panels[0]?.items?.length && ( + + setIsActionMenuOpen(!isActionMenuOpen)} + /> + } + isOpen={isActionMenuOpen} + closePopover={() => setIsActionMenuOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + + )} {dashboardAttachmentReady ? ( ) : null} - + ); }; diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_attachments_menu.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_attachments_menu.tsx new file mode 100644 index 0000000000000..c8c9bad1568a4 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_attachments_menu.tsx @@ -0,0 +1,176 @@ +/* + * 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 { SaveModalDashboardProps } from '@kbn/presentation-util-plugin/public'; +import { LazySavedObjectSaveModalDashboard } from '@kbn/presentation-util-plugin/public'; +import { withSuspense } from '@kbn/shared-ux-utility'; +import React, { useState, useCallback, useMemo } from 'react'; +import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; +import { EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE } from '@kbn/aiops-log-rate-analysis/constants'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import type { EuiContextMenuProps } from '@elastic/eui'; +import { + EuiButton, + EuiButtonIcon, + EuiContextMenu, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPanel, + EuiPopover, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import { useDataSource } from '../../../hooks/use_data_source'; +import type { LogRateAnalysisEmbeddableState } from '../../../embeddables/log_rate_analysis/types'; +import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context'; + +const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); + +export const LogRateAnalysisAttachmentsMenu = () => { + const { + application: { capabilities }, + embeddable, + } = useAiopsAppContext(); + const { dataView } = useDataSource(); + + const [applyTimeRange, setApplyTimeRange] = useState(false); + const [isActionMenuOpen, setIsActionMenuOpen] = useState(false); + const [dashboardAttachmentReady, setDashboardAttachmentReady] = useState(false); + + const timeRange = useTimeRangeUpdates(); + + const canEditDashboards = capabilities.dashboard.createNew; + + const onSave: SaveModalDashboardProps['onSave'] = useCallback( + ({ dashboardId, newTitle, newDescription }) => { + const stateTransfer = embeddable!.getStateTransfer(); + + const embeddableInput: Partial = { + title: newTitle, + description: newDescription, + dataViewId: dataView.id, + hidePanelTitles: false, + ...(applyTimeRange && { timeRange }), + }; + + const state = { + input: embeddableInput, + type: EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE, + }; + + const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; + + stateTransfer.navigateToWithEmbeddablePackage('dashboards', { state, path }); + }, + [dataView.id, embeddable, applyTimeRange, timeRange] + ); + + const panels = useMemo>(() => { + return [ + { + id: 'attachMainPanel', + size: 's', + items: [ + ...(canEditDashboards + ? [ + { + name: i18n.translate('xpack.aiops.logRateAnalysis.addToDashboardTitle', { + defaultMessage: 'Add to dashboard', + }), + panel: 'attachToDashboardPanel', + 'data-test-subj': 'aiopsLogRateAnalysisAttachToDashboardButton', + }, + ] + : []), + ], + }, + { + id: 'attachToDashboardPanel', + size: 's', + title: i18n.translate('xpack.aiops.logRateAnalysis.attachToDashboardTitle', { + defaultMessage: 'Add to dashboard', + }), + content: ( + + + + + setApplyTimeRange(e.target.checked)} + /> + + + { + setIsActionMenuOpen(false); + setDashboardAttachmentReady(true); + }} + > + + + + + ), + }, + ]; + }, [canEditDashboards, applyTimeRange]); + + return ( + <> + {!!panels[0]?.items?.length && ( + + setIsActionMenuOpen(!isActionMenuOpen)} + /> + } + isOpen={isActionMenuOpen} + closePopover={() => setIsActionMenuOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + + )} + {dashboardAttachmentReady ? ( + setDashboardAttachmentReady(false)} + onSave={onSave} + /> + ) : null} + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx index 2821b59353b52..29ac8d0efffc8 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx @@ -38,6 +38,7 @@ import { type LogRateAnalysisResultsData, } from '../log_rate_analysis_results'; import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context'; +import { LogRateAnalysisAttachmentsMenu } from './log_rate_analysis_attachments_menu'; export const DEFAULT_SEARCH_QUERY: estypes.QueryDslQueryContainer = { match_all: {} }; const DEFAULT_SEARCH_BAR_QUERY: estypes.QueryDslQueryContainer = { @@ -216,6 +217,7 @@ export const LogRateAnalysisContent: FC = ({ barColorOverride={barColorOverride} barHighlightColorOverride={barHighlightColorOverride} barStyleAccessor={barStyleAccessor} + attachmentsMenu={} /> )} diff --git a/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx b/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx index d24b5ab8498b0..d06c46cc6f71e 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx @@ -59,6 +59,7 @@ export const LogRateAnalysisPage: FC = () => { 'uiSettings', 'unifiedSearch', 'observabilityAIAssistant', + 'embeddable', ]), }} /> diff --git a/x-pack/test/functional/apps/aiops/log_rate_analysis.ts b/x-pack/test/functional/apps/aiops/log_rate_analysis.ts index 8ffbea4f1a0b0..e0178cb13fe9f 100644 --- a/x-pack/test/functional/apps/aiops/log_rate_analysis.ts +++ b/x-pack/test/functional/apps/aiops/log_rate_analysis.ts @@ -348,6 +348,24 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await elasticChart.setNewChartUiDebugFlag(true); }); + it(`${testData.suiteTitle} attaches log rate analysis to a dashboard`, async () => { + await aiops.logRateAnalysisPage.navigateToDataViewSelection(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the log rate analysis page with selected data source` + ); + await ml.jobSourceSelection.selectSourceForLogRateAnalysis( + testData.sourceIndexOrSavedSearch + ); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} starting dashboard attachment process` + ); + await aiops.logRateAnalysisPage.attachToDashboard(); + + await ml.navigation.navigateToMl(); + }); + runTests(testData); }); } diff --git a/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts b/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts index 0f7b14e3e8be7..3da7659419f0f 100644 --- a/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts +++ b/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts @@ -21,6 +21,7 @@ export function LogRateAnalysisPageProvider({ getService, getPageObject }: FtrPr const testSubjects = getService('testSubjects'); const retry = getService('retry'); const header = getPageObject('header'); + const dashboardPage = getPageObject('dashboard'); return { async assertTimeRangeSelectorSectionExists() { @@ -387,5 +388,52 @@ export function LogRateAnalysisPageProvider({ getService, getPageObject }: FtrPr { location: handle, offset: { x: dragAndDropOffsetPx, y: 0 } } ); }, + + async openAttachmentsMenu() { + await testSubjects.click('aiopsLogRateAnalysisAttachmentsMenuButton'); + }, + + async clickAttachToDashboard() { + await testSubjects.click('aiopsLogRateAnalysisAttachToDashboardButton'); + }, + + async confirmAttachToDashboard() { + await testSubjects.click('aiopsLogRateAnalysisAttachToDashboardSubmitButton'); + }, + + async completeSaveToDashboardForm(createNew?: boolean) { + const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); + if (createNew) { + const label = await dashboardSelector.findByCssSelector( + `label[for="new-dashboard-option"]` + ); + await label.click(); + } + + await testSubjects.click('confirmSaveSavedObjectButton'); + await retry.waitForWithTimeout('Save modal to disappear', 1000, () => + testSubjects + .missingOrFail('confirmSaveSavedObjectButton') + .then(() => true) + .catch(() => false) + ); + + // make sure the dashboard page actually loaded + const dashboardItemCount = await dashboardPage.getSharedItemsCount(); + expect(dashboardItemCount).to.not.eql(undefined); + + const embeddable = await testSubjects.find('aiopsEmbeddableLogRateAnalysis', 30 * 1000); + expect(await embeddable.isDisplayed()).to.eql( + true, + 'Log rate analysis chart should be displayed in dashboard' + ); + }, + + async attachToDashboard() { + await this.openAttachmentsMenu(); + await this.clickAttachToDashboard(); + await this.confirmAttachToDashboard(); + await this.completeSaveToDashboardForm(true); + }, }; } From 20749d05d035bcf495b4ec84c9e0b43548dd2343 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 20 Nov 2024 01:15:13 +0000 Subject: [PATCH 60/61] skip flaky suite (#197912) --- .../service_group_count/service_group_count.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts b/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts index 8b43114ba0ed6..24a38cfa8e356 100644 --- a/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts +++ b/x-pack/test/apm_api_integration/tests/service_groups/service_group_count/service_group_count.spec.ts @@ -45,8 +45,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); } - // FLAKY: https://github.com/elastic/kibana/issues/177655 - registry.when('Service group counts', { config: 'basic', archives: [] }, () => { + // FLAKY: https://github.com/elastic/kibana/issues/197912 + registry.when.skip('Service group counts', { config: 'basic', archives: [] }, () => { let synthbeansServiceGroupId: string; let opbeansServiceGroupId: string; before(async () => { From d697a67a05325ae71384e6f1c26235fce4924005 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Tue, 19 Nov 2024 20:08:48 -0800 Subject: [PATCH 61/61] [uiActions] Catch errors in isCompatible (#200261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Related to https://github.com/elastic/kibana/issues/197870. The bug reported in #197870 was a side effect of unhandled errors in the `isCompatible` check on the edit drilldown `uiAction`. When errors are throw in the `isCompatible` check on an `Action`, we should return `false` instead of throwing to unblock the rest of the compatibility checks. Screenshot 2024-11-14 at 3 30 20 PM ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_node:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --- .../ui_actions/public/actions/action_internal.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index ccef920ecc465..6f979849bdc41 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -29,6 +29,7 @@ export class ActionInternal public readonly subscribeToCompatibilityChanges?: Action['subscribeToCompatibilityChanges']; public readonly couldBecomeCompatible?: Action['couldBecomeCompatible']; + public errorLogged?: boolean; constructor(public readonly definition: ActionDefinition) { this.id = this.definition.id; @@ -38,6 +39,7 @@ export class ActionInternal this.grouping = this.definition.grouping; this.showNotification = this.definition.showNotification; this.disabled = this.definition.disabled; + this.errorLogged = false; if (this.definition.subscribeToCompatibilityChanges) { this.subscribeToCompatibilityChanges = definition.subscribeToCompatibilityChanges; @@ -77,7 +79,16 @@ export class ActionInternal public async isCompatible(context: Context): Promise { if (!this.definition.isCompatible) return true; - return await this.definition.isCompatible(context); + try { + return await this.definition.isCompatible(context); + } catch (e) { + if (!this.errorLogged) { + // eslint-disable-next-line no-console + console.error(e); + this.errorLogged = true; + } + return false; + } } public async getHref(context: Context): Promise {