From 6393a9125be44f3f46078a51a5ec2fa58857c530 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Wed, 4 Oct 2023 16:21:34 +0200 Subject: [PATCH] [Security Solution] Integrate default shared-ux left navigation (#167127) ## Summary Main ticket: https://github.com/elastic/kibana/issues/166545 This PR integrates the shared-ux `DefaultNavigation` component in the Security Solution project for serverless. These changes do not replace the original Security left navigation yet, which is still the navigation component displayed by default. In order to render the shared-ux `DefaultNavigation` this experimental flag should be enabled: `xpack.securitySolutionServerless.enableExperimental: ['platformNavEnabled']` Captura de pantalla 2023-09-25 a les 14 00 49 ## Implementation - Security navigation is still the default. Please enable the `platformNavEnabled` experimental flag to render the shared navigation. - We have two different formatters from the security navigation links config to the navigation tree required in serverless: - ChromeNavigationTree: registered directly to the serverless plugin for the breadcrumbs to work when the navigation is customized with the Security-specific nav. It will be removed after the migration to the shared nav. - NavigationTree: the format the shared nav uses, it already registers the chromeNavigationTree for the breadcrumbs to the serverless plugin by itself. - Security plugin `deepLinks` needed to be formatted differently to make this shared navigation work, since the `navLinkStatus: hidden` prevents the links from being processed and displayed, this has been solved via the `setDeepLinksFormatter` exposed on the plugin setup contract. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../security-solution/side_nav/panel.ts | 8 + .../public/common/links/deep_links.ts | 13 +- .../plugins/security_solution/public/mocks.ts | 3 + .../security_solution/public/plugin.tsx | 6 +- .../public/plugin_contract.ts | 9 +- .../plugins/security_solution/public/types.ts | 4 + .../app_features_service.ts | 2 +- .../server/lib/app_features_service/types.ts | 3 +- .../security_solution/server/plugin.ts | 1 + .../server/plugin_contract.ts | 5 + .../security_app_features_config.ts | 6 +- .../common/config.ts | 23 ++ .../common/experimental_features.ts | 70 +++++ .../services/__mocks__/services.mock.tsx | 3 + .../public/common/services/create_services.ts | 18 +- .../public/common/services/types.ts | 2 + .../public/navigation/default_navigation.tsx | 47 ++++ .../public/navigation/index.ts | 45 ++- .../public/navigation/links/app_links.ts | 59 ++-- .../public/navigation/links/deep_links.ts | 24 ++ .../public/navigation/links/nav.links.test.ts | 26 +- .../public/navigation/links/nav_links.ts | 19 +- .../public/navigation/navigation_tree.test.ts | 265 ------------------ .../chrome_navigation_tree.test.ts | 245 ++++++++++++++++ .../chrome_navigation_tree.ts} | 49 +--- .../navigation/navigation_tree/index.ts | 44 +++ .../navigation_tree/navigation_tree.test.ts | 209 ++++++++++++++ .../navigation_tree/navigation_tree.ts | 182 ++++++++++++ .../navigation/navigation_tree/utils.ts | 34 +++ .../public/plugin.ts | 25 +- .../public/types.ts | 10 +- .../server/app_features/index.ts | 9 +- .../security_app_features_config.ts | 7 +- .../server/config.ts | 66 ++++- .../server/plugin.ts | 14 +- .../tsconfig.json | 3 +- 37 files changed, 1156 insertions(+), 404 deletions(-) create mode 100644 x-pack/packages/security-solution/side_nav/panel.ts create mode 100644 x-pack/plugins/security_solution_serverless/common/experimental_features.ts create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/links/deep_links.ts delete mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts rename x-pack/plugins/security_solution_serverless/public/navigation/{navigation_tree.ts => navigation_tree/chrome_navigation_tree.ts} (62%) create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts create mode 100644 x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/utils.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index d29d129097f21..1bbc4d08247a2 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -124,7 +124,7 @@ pageLoadAssetSize: security: 81771 securitySolution: 66738 securitySolutionEss: 16573 - securitySolutionServerless: 45000 + securitySolutionServerless: 62488 serverless: 16573 serverlessObservability: 68747 serverlessSearch: 71995 diff --git a/x-pack/packages/security-solution/side_nav/panel.ts b/x-pack/packages/security-solution/side_nav/panel.ts new file mode 100644 index 0000000000000..a0341cd000812 --- /dev/null +++ b/x-pack/packages/security-solution/side_nav/panel.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 { SolutionSideNavPanel } from './src/solution_side_nav_panel'; diff --git a/x-pack/plugins/security_solution/public/common/links/deep_links.ts b/x-pack/plugins/security_solution/public/common/links/deep_links.ts index c8b38042ddd7e..a85a250104816 100644 --- a/x-pack/plugins/security_solution/public/common/links/deep_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/deep_links.ts @@ -11,7 +11,9 @@ import type { AppDeepLink, AppUpdater } from '@kbn/core/public'; import { appLinks$ } from './links'; import type { AppLinkItems } from './types'; -const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => +export type DeepLinksFormatter = (appLinks: AppLinkItems) => AppDeepLink[]; + +const defaultDeepLinksFormatter: DeepLinksFormatter = (appLinks) => appLinks.map((appLink) => ({ id: appLink.id, path: appLink.path, @@ -23,7 +25,7 @@ const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => ...(appLink.globalSearchKeywords != null ? { keywords: appLink.globalSearchKeywords } : {}), ...(appLink.links && appLink.links?.length ? { - deepLinks: formatDeepLinks(appLink.links), + deepLinks: defaultDeepLinksFormatter(appLink.links), } : {}), })); @@ -31,11 +33,14 @@ const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => /** * Registers any change in appLinks to be updated in app deepLinks */ -export const registerDeepLinksUpdater = (appUpdater$: Subject): Subscription => { +export const registerDeepLinksUpdater = ( + appUpdater$: Subject, + formatter: DeepLinksFormatter = defaultDeepLinksFormatter +): Subscription => { return appLinks$.subscribe((appLinks) => { appUpdater$.next(() => ({ navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent main security link to switch to visible after update - deepLinks: formatDeepLinks(appLinks), + deepLinks: formatter(appLinks), })); }); }; diff --git a/x-pack/plugins/security_solution/public/mocks.ts b/x-pack/plugins/security_solution/public/mocks.ts index ad129977e7a79..cab7df450eecc 100644 --- a/x-pack/plugins/security_solution/public/mocks.ts +++ b/x-pack/plugins/security_solution/public/mocks.ts @@ -9,6 +9,7 @@ import { BehaviorSubject, of } from 'rxjs'; import { UpsellingService } from '@kbn/security-solution-upselling/service'; import type { BreadcrumbsNav } from './common/breadcrumbs'; import type { NavigationLink } from './common/links/types'; +import { allowedExperimentalValues } from '../common/experimental_features'; import type { PluginStart, PluginSetup, ContractStartServices } from './types'; const upselling = new UpsellingService(); @@ -23,7 +24,9 @@ export const contractStartServicesMock: ContractStartServices = { const setupMock = (): PluginSetup => ({ resolver: jest.fn(), + experimentalFeatures: allowedExperimentalValues, // default values setAppLinksSwitcher: jest.fn(), + setDeepLinksFormatter: jest.fn(), setDataQualityPanelConfig: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 65453e37d686b..7d6d95a266596 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -95,7 +95,7 @@ export class Plugin implements IPlugin; public appLinksSwitcher: AppLinksSwitcher; + public deepLinksFormatter?: DeepLinksFormatter; public dataQualityPanelConfig?: DataQualityPanelConfig; - constructor() { + constructor(private readonly experimentalFeatures: ExperimentalFeatures) { this.extraRoutes$ = new BehaviorSubject([]); this.isSidebarEnabled$ = new BehaviorSubject(true); this.componentsService = new ContractComponentsService(); @@ -44,9 +47,13 @@ export class PluginContract { public getSetupContract(): PluginSetup { return { resolver: lazyResolver, + experimentalFeatures: { ...this.experimentalFeatures }, setAppLinksSwitcher: (appLinksSwitcher) => { this.appLinksSwitcher = appLinksSwitcher; }, + setDeepLinksFormatter: (deepLinksFormatter) => { + this.deepLinksFormatter = deepLinksFormatter; + }, setDataQualityPanelConfig: (dataQualityPanelConfig) => { this.dataQualityPanelConfig = dataQualityPanelConfig; }, diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 019b6ec13a39c..45d96af860e1e 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -79,6 +79,8 @@ import type { TelemetryClientStart } from './common/lib/telemetry'; import type { Dashboards } from './dashboards'; import type { BreadcrumbsNav } from './common/breadcrumbs/types'; import type { TopValuesPopoverService } from './app/components/top_values_popover/top_values_popover_service'; +import type { ExperimentalFeatures } from '../common/experimental_features'; +import type { DeepLinksFormatter } from './common/links/deep_links'; import type { DataQualityPanelConfig } from './overview/types'; import type { SetComponents, GetComponent$ } from './contract_components'; @@ -176,7 +178,9 @@ export type StartServices = CoreStart & export interface PluginSetup { resolver: () => Promise; + experimentalFeatures: ExperimentalFeatures; setAppLinksSwitcher: (appLinksSwitcher: AppLinksSwitcher) => void; + setDeepLinksFormatter: (deepLinksFormatter: DeepLinksFormatter) => void; setDataQualityPanelConfig: (dataQualityPanelConfig: DataQualityPanelConfig) => void; } diff --git a/x-pack/plugins/security_solution/server/lib/app_features_service/app_features_service.ts b/x-pack/plugins/security_solution/server/lib/app_features_service/app_features_service.ts index 97fcf6cf67ed6..fb8947f5cc47b 100644 --- a/x-pack/plugins/security_solution/server/lib/app_features_service/app_features_service.ts +++ b/x-pack/plugins/security_solution/server/lib/app_features_service/app_features_service.ts @@ -75,7 +75,7 @@ export class AppFeaturesService { } public setAppFeaturesConfigurator(configurator: AppFeaturesConfigurator) { - const securityAppFeaturesConfig = configurator.security(this.experimentalFeatures); + const securityAppFeaturesConfig = configurator.security(); this.securityAppFeatures.setConfig(securityAppFeaturesConfig); const casesAppFeaturesConfig = configurator.cases(); diff --git a/x-pack/plugins/security_solution/server/lib/app_features_service/types.ts b/x-pack/plugins/security_solution/server/lib/app_features_service/types.ts index b2d1054985c4c..a8c8f7582ac42 100644 --- a/x-pack/plugins/security_solution/server/lib/app_features_service/types.ts +++ b/x-pack/plugins/security_solution/server/lib/app_features_service/types.ts @@ -11,10 +11,9 @@ import type { CasesSubFeatureId, AssistantSubFeatureId, } from '@kbn/security-solution-features/keys'; -import type { ExperimentalFeatures } from '../../../common'; export interface AppFeaturesConfigurator { - security: (experimentalFlags: ExperimentalFeatures) => AppFeaturesConfig; + security: () => AppFeaturesConfig; cases: () => AppFeaturesConfig; securityAssistant: () => AppFeaturesConfig; } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index c1c2e00e8bfd0..fdc4f9f92816d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -456,6 +456,7 @@ export class Plugin implements ISecuritySolutionPlugin { return { setAppFeaturesConfigurator: appFeaturesService.setAppFeaturesConfigurator.bind(appFeaturesService), + experimentalFeatures: { ...config.experimentalFeatures }, }; } diff --git a/x-pack/plugins/security_solution/server/plugin_contract.ts b/x-pack/plugins/security_solution/server/plugin_contract.ts index 0c34bede016f1..a02b3ebbc5384 100644 --- a/x-pack/plugins/security_solution/server/plugin_contract.ts +++ b/x-pack/plugins/security_solution/server/plugin_contract.ts @@ -42,6 +42,7 @@ import type { SharePluginStart } from '@kbn/share-plugin/server'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server'; import type { AppFeaturesService } from './lib/app_features_service/app_features_service'; +import type { ExperimentalFeatures } from '../common'; export interface SecuritySolutionPluginSetupDependencies { alerting: AlertingPluginSetup; @@ -87,6 +88,10 @@ export interface SecuritySolutionPluginSetup { * Sets the configurations for app features that are available to the Security Solution */ setAppFeaturesConfigurator: AppFeaturesService['setAppFeaturesConfigurator']; + /** + * The security solution generic experimental features + */ + experimentalFeatures: ExperimentalFeatures; } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/x-pack/plugins/security_solution_ess/server/app_features/security_app_features_config.ts b/x-pack/plugins/security_solution_ess/server/app_features/security_app_features_config.ts index 6badb63d30ed1..18da55a377746 100644 --- a/x-pack/plugins/security_solution_ess/server/app_features/security_app_features_config.ts +++ b/x-pack/plugins/security_solution_ess/server/app_features/security_app_features_config.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { ExperimentalFeatures } from '@kbn/security-solution-plugin/common'; import type { AppFeatureKeys, AppFeatureKibanaConfig, @@ -24,10 +23,7 @@ import { } from '@kbn/security-solution-features/privileges'; export const getSecurityAppFeaturesConfigurator = - (enabledAppFeatureKeys: AppFeatureKeys) => - ( - _: ExperimentalFeatures // currently un-used, but left here as a convenience for possible future use - ): AppFeaturesSecurityConfig => { + (enabledAppFeatureKeys: AppFeatureKeys) => (): AppFeaturesSecurityConfig => { return createEnabledAppFeaturesConfigMap(securityAppFeaturesConfig, enabledAppFeatureKeys); }; diff --git a/x-pack/plugins/security_solution_serverless/common/config.ts b/x-pack/plugins/security_solution_serverless/common/config.ts index 50bfc71c7ec7d..b1aaef412fcb1 100644 --- a/x-pack/plugins/security_solution_serverless/common/config.ts +++ b/x-pack/plugins/security_solution_serverless/common/config.ts @@ -51,3 +51,26 @@ export const developerConfigSchema = schema.object({ }); export type DeveloperConfig = TypeOf; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + developer: developerConfigSchema, + productTypes, + /** + * For internal use. A list of string values (comma delimited) that will enable experimental + * type of functionality that is not yet released. Valid values for this settings need to + * be defined in: + * `x-pack/plugins/security_solution_serverless/common/experimental_features.ts` + * under the `allowedExperimentalValues` object + * + * @example + * xpack.securitySolutionServerless.enableExperimental: + * - someCrazyFeature + * - someEvenCrazierFeature + */ + enableExperimental: schema.arrayOf(schema.string(), { + defaultValue: () => [], + }), +}); + +export type ServerlessSecurityConfigSchema = TypeOf; diff --git a/x-pack/plugins/security_solution_serverless/common/experimental_features.ts b/x-pack/plugins/security_solution_serverless/common/experimental_features.ts new file mode 100644 index 0000000000000..500bad2a0483d --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/common/experimental_features.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 { ExperimentalFeatures as GenericExperimentalFeatures } from '@kbn/security-solution-plugin/common'; + +export type ServerlessExperimentalFeatures = Record< + keyof typeof allowedExperimentalValues, + boolean +>; + +/** + * A list of allowed values that can be used in `xpack.securitySolutionServerless.enableExperimental`. + * This object is then used to validate and parse the value entered. + */ +export const allowedExperimentalValues = Object.freeze({ + /** + * Enables the use of the of the product navigation from shared-ux package in the Security Solution app + */ + platformNavEnabled: false, +}); + +type ServerlessExperimentalConfigKeys = Array; +type Mutable = { -readonly [P in keyof T]: T[P] }; + +const allowedKeys = Object.keys( + allowedExperimentalValues +) as Readonly; + +export type ExperimentalFeatures = ServerlessExperimentalFeatures & GenericExperimentalFeatures; +/** + * Parses the string value used in `xpack.securitySolutionServerless.enableExperimental` kibana configuration, + * which should be a string of values delimited by a comma (`,`) + * The generic experimental features are merged with the serverless values to ensure they are available + * + * @param configValue + * @throws SecuritySolutionInvalidExperimentalValue + */ +export const parseExperimentalConfigValue = ( + configValue: string[], + genericExperimentalFeatures: GenericExperimentalFeatures +): { features: ExperimentalFeatures; invalid: string[]; duplicated: string[] } => { + const enabledFeatures: Mutable> = {}; + const invalidKeys: string[] = []; + const duplicatedKeys: string[] = []; + + for (const value of configValue) { + if (genericExperimentalFeatures[value as keyof GenericExperimentalFeatures] != null) { + duplicatedKeys.push(value); + } else if (!allowedKeys.includes(value as keyof ServerlessExperimentalFeatures)) { + invalidKeys.push(value); + } else { + enabledFeatures[value as keyof ServerlessExperimentalFeatures] = true; + } + } + + return { + features: { + ...genericExperimentalFeatures, + ...allowedExperimentalValues, + ...enabledFeatures, + }, + invalid: invalidKeys, + duplicated: duplicatedKeys, + }; +}; + +export const getExperimentalAllowedValues = (): string[] => [...allowedKeys]; diff --git a/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx b/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx index 87e22e80a59b1..de3d846253d13 100644 --- a/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx +++ b/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx @@ -13,11 +13,14 @@ import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import type { ProjectNavigationLink } from '../../../navigation/links/types'; import type { Services } from '..'; +import { allowedExperimentalValues as genericAllowedExperimentalValues } from '@kbn/security-solution-plugin/common'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; export const mockProjectNavLinks = jest.fn((): ProjectNavigationLink[] => []); export const mockServices: Services = { ...coreMock.createStart(), + experimentalFeatures: { ...allowedExperimentalValues, ...genericAllowedExperimentalValues }, serverless: serverlessMock.createStart(), security: securityMock.createStart(), securitySolution: securitySolutionMock.createStart(), diff --git a/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts b/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts index 9a16ebe31ff08..7967f8d00d6fb 100644 --- a/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts +++ b/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts @@ -6,6 +6,7 @@ */ import type { CoreStart } from '@kbn/core/public'; +import type { ExperimentalFeatures } from '../../../common/experimental_features'; import { createProjectNavLinks$ } from '../../navigation/links/nav_links'; import type { SecuritySolutionServerlessPluginStartDeps } from '../../types'; import type { Services } from './types'; @@ -16,9 +17,20 @@ import type { Services } from './types'; * */ export const createServices = ( core: CoreStart, - pluginsStart: SecuritySolutionServerlessPluginStartDeps + pluginsStart: SecuritySolutionServerlessPluginStartDeps, + experimentalFeatures: ExperimentalFeatures ): Services => { const { securitySolution, cloud } = pluginsStart; - const projectNavLinks$ = createProjectNavLinks$(securitySolution.getNavLinks$(), core, cloud); - return { ...core, ...pluginsStart, getProjectNavLinks$: () => projectNavLinks$ }; + const projectNavLinks$ = createProjectNavLinks$( + securitySolution.getNavLinks$(), + core, + cloud, + experimentalFeatures + ); + return { + ...core, + ...pluginsStart, + experimentalFeatures, + getProjectNavLinks$: () => projectNavLinks$, + }; }; diff --git a/x-pack/plugins/security_solution_serverless/public/common/services/types.ts b/x-pack/plugins/security_solution_serverless/public/common/services/types.ts index c655f7db6898d..317319c32b4d6 100644 --- a/x-pack/plugins/security_solution_serverless/public/common/services/types.ts +++ b/x-pack/plugins/security_solution_serverless/public/common/services/types.ts @@ -6,10 +6,12 @@ */ import type { CoreStart } from '@kbn/core/public'; +import type { ExperimentalFeatures } from '../../../common/experimental_features'; import type { ProjectNavLinks } from '../../navigation/links/types'; import type { SecuritySolutionServerlessPluginStartDeps } from '../../types'; export interface InternalServices { + experimentalFeatures: ExperimentalFeatures; getProjectNavLinks$: () => ProjectNavLinks; } export type Services = CoreStart & SecuritySolutionServerlessPluginStartDeps & InternalServices; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx new file mode 100644 index 0000000000000..74f3d0e3afd3d --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { NavigationTreeDefinition } from '@kbn/shared-ux-chrome-navigation'; +import type { SideNavComponent } from '@kbn/core-chrome-browser'; +import type { Services } from '../common/services'; + +const SecurityDefaultNavigationLazy = React.lazy(() => + import('@kbn/shared-ux-chrome-navigation').then( + ({ DefaultNavigation, NavigationKibanaProvider }) => ({ + default: React.memo<{ + navigationTree: NavigationTreeDefinition; + services: Services; + }>(function SecurityDefaultNavigation({ navigationTree, services }) { + return ( + + + + ); + }), + }) + ) +); + +export const getDefaultNavigationComponent = ( + navigationTree: NavigationTreeDefinition, + services: Services +): SideNavComponent => + function SecuritySideNavComponent() { + return ( + }> + + + ); + }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/index.ts b/x-pack/plugins/security_solution_serverless/public/navigation/index.ts index 19684479e7dd6..0a5f08261eb24 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/index.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/index.ts @@ -6,32 +6,55 @@ */ import { APP_PATH, SecurityPageName } from '@kbn/security-solution-plugin/common'; -import type { ServerlessSecurityPublicConfig } from '../types'; +import type { CoreSetup } from '@kbn/core/public'; +import type { + SecuritySolutionServerlessPluginSetupDeps, + ServerlessSecurityPublicConfig, +} from '../types'; import type { Services } from '../common/services'; import { subscribeBreadcrumbs } from './breadcrumbs'; import { SecurityPagePath } from './links/constants'; -import { subscribeNavigationTree } from './navigation_tree'; +import { ProjectNavigationTree } from './navigation_tree'; import { getSecuritySideNavComponent } from './side_navigation'; +import { getDefaultNavigationComponent } from './default_navigation'; +import { getProjectAppLinksSwitcher } from './links/app_links'; +import { formatProjectDeepLinks } from './links/deep_links'; +import type { ExperimentalFeatures } from '../../common/experimental_features'; const SECURITY_PROJECT_SETTINGS_PATH = `${APP_PATH}${ SecurityPagePath[SecurityPageName.projectSettings] }`; -export const configureNavigation = ( - services: Services, - serverConfig: ServerlessSecurityPublicConfig +export const setupNavigation = ( + _core: CoreSetup, + { securitySolution }: SecuritySolutionServerlessPluginSetupDeps, + experimentalFeatures: ExperimentalFeatures ) => { + securitySolution.setAppLinksSwitcher(getProjectAppLinksSwitcher(experimentalFeatures)); + securitySolution.setDeepLinksFormatter(formatProjectDeepLinks); +}; + +export const startNavigation = (services: Services, config: ServerlessSecurityPublicConfig) => { const { serverless, securitySolution, management } = services; securitySolution.setIsSidebarEnabled(false); + serverless.setProjectHome(APP_PATH); - if (!serverConfig.developer.disableManagementUrlRedirect) { - management.setLandingPageRedirect(SECURITY_PROJECT_SETTINGS_PATH); + const projectNavigationTree = new ProjectNavigationTree(services); + + if (services.experimentalFeatures.platformNavEnabled) { + projectNavigationTree.getNavigationTree$().subscribe((navigationTree) => { + serverless.setSideNavComponent(getDefaultNavigationComponent(navigationTree, services)); + }); + } else { + if (!config.developer.disableManagementUrlRedirect) { + management.setLandingPageRedirect(SECURITY_PROJECT_SETTINGS_PATH); + } + projectNavigationTree.getChromeNavigationTree$().subscribe((chromeNavigationTree) => { + serverless.setNavigation({ navigationTree: chromeNavigationTree }); + }); + serverless.setSideNavComponent(getSecuritySideNavComponent(services)); } management.setIsSidebarEnabled(false); - serverless.setProjectHome(APP_PATH); - serverless.setSideNavComponent(getSecuritySideNavComponent(services)); - - subscribeNavigationTree(services); subscribeBreadcrumbs(services); }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts index 49c79ac12d8f6..4cb0cc4a7fd7c 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts @@ -15,35 +15,38 @@ import { createInvestigationsLinkFromTimeline } from './sections/investigations_ import { mlAppLink } from './sections/ml_links'; import { createAssetsLinkFromManage } from './sections/assets_links'; import { createProjectSettingsLinkFromManage } from './sections/project_settings_links'; +import type { ExperimentalFeatures } from '../../../common/experimental_features'; // This function is called by the security_solution plugin to alter the app links // that will be registered to the Security Solution application on Serverless projects. // The capabilities filtering is done after this function is called by the security_solution plugin. -export const projectAppLinksSwitcher: AppLinksSwitcher = (appLinks) => { - const projectAppLinks = cloneDeep(appLinks) as LinkItem[]; - - // Remove timeline link - const [timelineLinkItem] = remove(projectAppLinks, { id: SecurityPageName.timelines }); - if (timelineLinkItem) { - // Add investigations link - projectAppLinks.push(createInvestigationsLinkFromTimeline(timelineLinkItem)); - } - - // Remove manage link - const [manageLinkItem] = remove(projectAppLinks, { id: SecurityPageName.administration }); - - if (manageLinkItem) { - // Add assets link - projectAppLinks.push(createAssetsLinkFromManage(manageLinkItem)); - } - - // Add ML link - projectAppLinks.push(mlAppLink); - - if (manageLinkItem) { - // Add project settings link - projectAppLinks.push(createProjectSettingsLinkFromManage(manageLinkItem)); - } - - return projectAppLinks; -}; +export const getProjectAppLinksSwitcher = + (experimentalFeatures: ExperimentalFeatures): AppLinksSwitcher => + (appLinks) => { + const projectAppLinks = cloneDeep(appLinks) as LinkItem[]; + + // Remove timeline link + const [timelineLinkItem] = remove(projectAppLinks, { id: SecurityPageName.timelines }); + if (timelineLinkItem) { + // Add investigations link + projectAppLinks.push(createInvestigationsLinkFromTimeline(timelineLinkItem)); + } + + // Remove manage link + const [manageLinkItem] = remove(projectAppLinks, { id: SecurityPageName.administration }); + + if (manageLinkItem) { + // Add assets link + projectAppLinks.push(createAssetsLinkFromManage(manageLinkItem)); + } + + // Add ML link + projectAppLinks.push(mlAppLink); + + if (!experimentalFeatures.platformNavEnabled && manageLinkItem) { + // Add project settings link + projectAppLinks.push(createProjectSettingsLinkFromManage(manageLinkItem)); + } + + return projectAppLinks; + }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/deep_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/deep_links.ts new file mode 100644 index 0000000000000..9ae7bf5553216 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/deep_links.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 { DeepLinksFormatter } from '@kbn/security-solution-plugin/public/common/links/deep_links'; +import { AppNavLinkStatus } from '@kbn/core/public'; + +export const formatProjectDeepLinks: DeepLinksFormatter = (appLinks) => + appLinks.map((appLink) => ({ + id: appLink.id, + path: appLink.path, + title: appLink.title, + searchable: !appLink.globalSearchDisabled, + navLinkStatus: appLink.sideNavDisabled ? AppNavLinkStatus.hidden : AppNavLinkStatus.visible, + ...(appLink.globalSearchKeywords != null ? { keywords: appLink.globalSearchKeywords } : {}), + ...(appLink.links && appLink.links?.length + ? { + deepLinks: formatProjectDeepLinks(appLink.links), + } + : {}), + })); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts index 11764c5a5aea3..0a20899462c20 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts @@ -21,6 +21,7 @@ import { projectSettingsNavLinks, } from './sections/project_settings_links'; import { isCloudLink } from './util'; +import type { ExperimentalFeatures } from '../../../common/experimental_features'; const mockCloudStart = mockServices.cloud; const mockChromeNavLinks = jest.fn((): ChromeNavLink[] => []); @@ -39,6 +40,7 @@ const testServices = { }, }, }; +const experimentalFeatures = { platformNavEnabled: false } as ExperimentalFeatures; const link1Id = 'link-1' as SecurityPageName; const link2Id = 'link-2' as SecurityPageName; @@ -86,7 +88,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); expect(value).toEqual([link1, link2]); @@ -99,7 +102,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); @@ -113,7 +117,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); @@ -131,7 +136,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); @@ -155,7 +161,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); @@ -178,7 +185,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); @@ -201,7 +209,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); @@ -233,7 +242,8 @@ describe('getProjectNavLinks', () => { const projectNavLinks$ = createProjectNavLinks$( testSecurityNavLinks$, testServices, - mockCloudStart + mockCloudStart, + experimentalFeatures ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts index acef7c10d8ca5..2da3279562191 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts @@ -10,6 +10,7 @@ import type { ChromeNavLinks, CoreStart } from '@kbn/core/public'; import { SecurityPageName, type NavigationLink } from '@kbn/security-solution-navigation'; import { isSecurityId } from '@kbn/security-solution-navigation/links'; import type { CloudStart } from '@kbn/cloud-plugin/public'; +import { remove } from 'lodash'; import { assetsNavLinks } from './sections/assets_links'; import { mlNavCategories, mlNavLinks } from './sections/ml_links'; import { @@ -20,11 +21,14 @@ import { devToolsNavLink } from './sections/dev_tools_links'; import type { ProjectNavigationLink } from './types'; import { getCloudLinkKey, getCloudUrl, getNavLinkIdFromProjectPageName, isCloudLink } from './util'; import { investigationsNavLinks } from './sections/investigations_links'; +import { ExternalPageName } from './constants'; +import type { ExperimentalFeatures } from '../../../common/experimental_features'; export const createProjectNavLinks$ = ( securityNavLinks$: Observable>>, core: CoreStart, - cloud: CloudStart + cloud: CloudStart, + experimentalFeatures: ExperimentalFeatures ): Observable => { const { chrome } = core; return combineLatest([securityNavLinks$, chrome.navLinks.getNavLinks$()]).pipe( @@ -33,7 +37,9 @@ export const createProjectNavLinks$ = ( ([securityNavLinks, chromeNavLinks]) => securityNavLinks.length === 0 || chromeNavLinks.length === 0 // skip if not initialized ), - map(([securityNavLinks]) => processNavLinks(securityNavLinks, chrome.navLinks, cloud)) + map(([securityNavLinks]) => + processNavLinks(securityNavLinks, chrome.navLinks, cloud, experimentalFeatures) + ) ); }; @@ -44,7 +50,8 @@ export const createProjectNavLinks$ = ( const processNavLinks = ( securityNavLinks: Array>, chromeNavLinks: ChromeNavLinks, - cloud: CloudStart + cloud: CloudStart, + experimentalFeatures: ExperimentalFeatures ): ProjectNavigationLink[] => { const projectNavLinks: ProjectNavigationLink[] = [...securityNavLinks]; @@ -96,6 +103,12 @@ const processNavLinks = ( // Dev Tools. just pushing it projectNavLinks.push(devToolsNavLink); + if (experimentalFeatures.platformNavEnabled) { + remove(projectNavLinks, { id: SecurityPageName.landing }); + remove(projectNavLinks, { id: ExternalPageName.devTools }); + remove(projectNavLinks, { id: SecurityPageName.projectSettings }); + } + return processCloudLinks(filterDisabled(projectNavLinks, chromeNavLinks), cloud); }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts deleted file mode 100644 index 56a0abacd4d94..0000000000000 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import type { ChromeNavLink } from '@kbn/core/public'; -import { APP_UI_ID } from '@kbn/security-solution-plugin/common'; -import { SecurityPageName } from '@kbn/security-solution-navigation'; -import { subscribeNavigationTree } from './navigation_tree'; -import { mockServices, mockProjectNavLinks } from '../common/services/__mocks__/services.mock'; -import type { ProjectNavigationLink } from './links/types'; -import type { ExternalPageName } from './links/constants'; -import * as ml from '@kbn/default-nav-ml'; - -jest.mock('@kbn/default-nav-ml'); - -const link1Id = 'link-1' as SecurityPageName; -const link2Id = 'link-2' as SecurityPageName; -const link3Id = 'externalAppId:link-1' as ExternalPageName; - -const link1: ProjectNavigationLink = { id: link1Id, title: 'link 1' }; -const link2: ProjectNavigationLink = { id: link2Id, title: 'link 2' }; -const link3: ProjectNavigationLink = { id: link3Id, title: 'link 3' }; - -const chromeNavLink1: ChromeNavLink = { - id: `${APP_UI_ID}:${link1.id}`, - title: link1.title, - href: '/link1', - url: '/link1', - baseUrl: '', -}; -const chromeNavLink2: ChromeNavLink = { - id: `${APP_UI_ID}:${link2.id}`, - title: link2.title, - href: '/link2', - url: '/link2', - baseUrl: '', -}; -const chromeNavLink3: ChromeNavLink = { - id: link3.id, - title: link3.title, - href: '/link3', - url: '/link3', - baseUrl: '', -}; -const chromeNavLinkMl1: ChromeNavLink = { - id: 'ml:subLink-1', - title: 'ML subLink 1', - href: '/ml/link1', - url: '/ml/link1', - baseUrl: '', -}; -const chromeNavLinkMl2: ChromeNavLink = { - id: 'ml:subLink-2', - title: 'ML subLink 2', - href: '/ml/link2', - url: '/ml/link2', - baseUrl: '', -}; -const defaultNavCategory1 = { - id: 'category_one', - title: 'ML Category1', -}; - -(ml as { defaultNavigation: unknown }).defaultNavigation = { - children: [ - { - id: 'root', - children: [ - { - link: chromeNavLinkMl1.id, - }, - ], - }, - { - ...defaultNavCategory1, - children: [ - { - title: 'Overridden ML SubLink 2', - link: chromeNavLinkMl2.id, - }, - ], - }, - ], -}; - -let chromeNavLinks: ChromeNavLink[] = []; -const mockChromeNavLinksGet = jest.fn((id: string): ChromeNavLink | undefined => - chromeNavLinks.find((link) => link.id === id) -); -const mockChromeNavLinksHas = jest.fn((id: string): boolean => - chromeNavLinks.some((link) => link.id === id) -); - -const testServices = { - ...mockServices, - chrome: { - ...mockServices.chrome, - navLinks: { - ...mockServices.chrome.navLinks, - get: mockChromeNavLinksGet, - has: mockChromeNavLinksHas, - }, - }, -}; - -describe('subscribeNavigationTree', () => { - beforeEach(() => { - jest.clearAllMocks(); - chromeNavLinks = [chromeNavLink1, chromeNavLink2, chromeNavLink3]; - }); - - it('should call serverless setNavigation', async () => { - mockProjectNavLinks.mockReturnValueOnce([link1]); - - subscribeNavigationTree(testServices); - - expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ - navigationTree: [ - { - id: chromeNavLink1.id, - title: link1.title, - path: [chromeNavLink1.id], - deepLink: chromeNavLink1, - }, - ], - }); - }); - - it('should call serverless setNavigation with external link', async () => { - mockProjectNavLinks.mockReturnValueOnce([link3]); - - subscribeNavigationTree(testServices); - - expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ - navigationTree: [ - { - id: chromeNavLink3.id, - title: chromeNavLink3.title, - path: [chromeNavLink3.id], - deepLink: chromeNavLink3, - }, - ], - }); - }); - - it('should call serverless setNavigation with nested children', async () => { - mockProjectNavLinks.mockReturnValueOnce([{ ...link1, links: [link2] }]); - - subscribeNavigationTree(testServices); - - expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ - navigationTree: [ - { - id: chromeNavLink1.id, - title: link1.title, - path: [chromeNavLink1.id], - deepLink: chromeNavLink1, - children: [ - { - id: chromeNavLink2.id, - title: link2.title, - path: [chromeNavLink1.id, chromeNavLink2.id], - deepLink: chromeNavLink2, - }, - ], - }, - ], - }); - }); - - it('should add default nav for ML page', async () => { - const chromeNavLinkTest = { - ...chromeNavLink1, - id: `${APP_UI_ID}:${SecurityPageName.mlLanding}`, - }; - chromeNavLinks = [chromeNavLinkTest, chromeNavLinkMl1, chromeNavLinkMl2]; - mockProjectNavLinks.mockReturnValueOnce([{ ...link1, id: SecurityPageName.mlLanding }]); - - subscribeNavigationTree(testServices); - - expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ - navigationTree: [ - { - id: chromeNavLinkTest.id, - title: link1.title, - path: [chromeNavLinkTest.id], - deepLink: chromeNavLinkTest, - children: [ - { - id: chromeNavLinkMl1.id, - title: chromeNavLinkMl1.title, - path: [chromeNavLinkTest.id, chromeNavLinkMl1.id], - deepLink: chromeNavLinkMl1, - }, - { - id: defaultNavCategory1.id, - title: defaultNavCategory1.title, - path: [chromeNavLinkTest.id, defaultNavCategory1.id], - children: [ - { - id: chromeNavLinkMl2.id, - title: 'Overridden ML SubLink 2', - path: [chromeNavLinkTest.id, defaultNavCategory1.id, chromeNavLinkMl2.id], - deepLink: chromeNavLinkMl2, - }, - ], - }, - ], - }, - ], - }); - }); - - it('should not include links that are not in the chrome navLinks', async () => { - chromeNavLinks = [chromeNavLink2]; - mockProjectNavLinks.mockReturnValueOnce([link1, link2]); - - subscribeNavigationTree(testServices); - - expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ - navigationTree: [ - { - id: chromeNavLink2.id, - title: link2.title, - path: [chromeNavLink2.id], - deepLink: chromeNavLink2, - }, - ], - }); - }); - - it('should set hidden breadcrumb for blacklisted links', async () => { - const chromeNavLinkTest = { - ...chromeNavLink1, - id: `${APP_UI_ID}:${SecurityPageName.usersEvents}`, // userEvents link is blacklisted - }; - chromeNavLinks = [chromeNavLinkTest, chromeNavLink2]; - mockProjectNavLinks.mockReturnValueOnce([ - { ...link1, id: SecurityPageName.usersEvents }, - link2, - ]); - - subscribeNavigationTree(testServices); - - expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ - navigationTree: [ - { - id: chromeNavLinkTest.id, - title: link1.title, - path: [chromeNavLinkTest.id], - deepLink: chromeNavLinkTest, - breadcrumbStatus: 'hidden', - }, - { - id: chromeNavLink2.id, - title: link2.title, - path: [chromeNavLink2.id], - deepLink: chromeNavLink2, - }, - ], - }); - }); -}); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts new file mode 100644 index 0000000000000..5b3502225e769 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ChromeNavLink } from '@kbn/core/public'; +import { APP_UI_ID } from '@kbn/security-solution-plugin/common'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { getFormatChromeProjectNavNodes } from './chrome_navigation_tree'; +import { mockServices } from '../../common/services/__mocks__/services.mock'; +import type { ProjectNavigationLink } from '../links/types'; +import type { ExternalPageName } from '../links/constants'; +import * as ml from '@kbn/default-nav-ml'; + +jest.mock('@kbn/default-nav-ml'); + +const link1Id = 'link-1' as SecurityPageName; +const link2Id = 'link-2' as SecurityPageName; +const link3Id = 'externalAppId:link-1' as ExternalPageName; + +const link1: ProjectNavigationLink = { id: link1Id, title: 'link 1' }; +const link2: ProjectNavigationLink = { id: link2Id, title: 'link 2' }; +const link3: ProjectNavigationLink = { id: link3Id, title: 'link 3' }; + +const chromeNavLink1: ChromeNavLink = { + id: `${APP_UI_ID}:${link1.id}`, + title: link1.title, + href: '/link1', + url: '/link1', + baseUrl: '', +}; +const chromeNavLink2: ChromeNavLink = { + id: `${APP_UI_ID}:${link2.id}`, + title: link2.title, + href: '/link2', + url: '/link2', + baseUrl: '', +}; +const chromeNavLink3: ChromeNavLink = { + id: link3.id, + title: link3.title, + href: '/link3', + url: '/link3', + baseUrl: '', +}; +const chromeNavLinkMl1: ChromeNavLink = { + id: 'ml:subLink-1', + title: 'ML subLink 1', + href: '/ml/link1', + url: '/ml/link1', + baseUrl: '', +}; +const chromeNavLinkMl2: ChromeNavLink = { + id: 'ml:subLink-2', + title: 'ML subLink 2', + href: '/ml/link2', + url: '/ml/link2', + baseUrl: '', +}; +const defaultNavCategory1 = { + id: 'category_one', + title: 'ML Category1', +}; + +(ml as { defaultNavigation: unknown }).defaultNavigation = { + children: [ + { + id: 'root', + children: [ + { + link: chromeNavLinkMl1.id, + }, + ], + }, + { + ...defaultNavCategory1, + children: [ + { + title: 'Overridden ML SubLink 2', + link: chromeNavLinkMl2.id, + }, + ], + }, + ], +}; + +let chromeNavLinks: ChromeNavLink[] = []; +const mockChromeNavLinksGet = jest.fn((id: string): ChromeNavLink | undefined => + chromeNavLinks.find((link) => link.id === id) +); +const mockChromeNavLinksHas = jest.fn((id: string): boolean => + chromeNavLinks.some((link) => link.id === id) +); + +const testServices = { + ...mockServices, + chrome: { + ...mockServices.chrome, + navLinks: { + ...mockServices.chrome.navLinks, + get: mockChromeNavLinksGet, + has: mockChromeNavLinksHas, + }, + }, +}; + +describe('formatChromeProjectNavNodes', () => { + const formatChromeProjectNavNodes = getFormatChromeProjectNavNodes(testServices); + + beforeEach(() => { + jest.clearAllMocks(); + chromeNavLinks = [chromeNavLink1, chromeNavLink2, chromeNavLink3]; + }); + + it('should format regular chrome nav nodes', async () => { + const chromeNavNodes = formatChromeProjectNavNodes([link1]); + expect(chromeNavNodes).toEqual([ + { + id: chromeNavLink1.id, + title: link1.title, + path: [chromeNavLink1.id], + deepLink: chromeNavLink1, + }, + ]); + }); + + it('should format external chrome nav nodes', async () => { + const chromeNavNodes = formatChromeProjectNavNodes([link3]); + + expect(chromeNavNodes).toEqual([ + { + id: chromeNavLink3.id, + title: chromeNavLink3.title, + path: [chromeNavLink3.id], + deepLink: chromeNavLink3, + }, + ]); + }); + + it('should format nested links to chrome nav nodes', async () => { + const chromeNavNodes = formatChromeProjectNavNodes([{ ...link1, links: [link2] }]); + + expect(chromeNavNodes).toEqual([ + { + id: chromeNavLink1.id, + title: link1.title, + path: [chromeNavLink1.id], + deepLink: chromeNavLink1, + children: [ + { + id: chromeNavLink2.id, + title: link2.title, + path: [chromeNavLink1.id, chromeNavLink2.id], + deepLink: chromeNavLink2, + }, + ], + }, + ]); + }); + + it('should use the preset nav for ML lings', async () => { + const chromeNavLinkTest = { + ...chromeNavLink1, + id: `${APP_UI_ID}:${SecurityPageName.mlLanding}`, + }; + chromeNavLinks = [chromeNavLinkTest, chromeNavLinkMl1, chromeNavLinkMl2]; + const chromeNavNodes = formatChromeProjectNavNodes([ + { ...link1, id: SecurityPageName.mlLanding }, + ]); + + expect(chromeNavNodes).toEqual([ + { + id: chromeNavLinkTest.id, + title: link1.title, + path: [chromeNavLinkTest.id], + deepLink: chromeNavLinkTest, + children: [ + { + id: chromeNavLinkMl1.id, + title: chromeNavLinkMl1.title, + path: [chromeNavLinkTest.id, chromeNavLinkMl1.id], + deepLink: chromeNavLinkMl1, + }, + { + id: defaultNavCategory1.id, + title: defaultNavCategory1.title, + path: [chromeNavLinkTest.id, defaultNavCategory1.id], + children: [ + { + id: chromeNavLinkMl2.id, + title: 'Overridden ML SubLink 2', + path: [chromeNavLinkTest.id, defaultNavCategory1.id, chromeNavLinkMl2.id], + deepLink: chromeNavLinkMl2, + }, + ], + }, + ], + }, + ]); + }); + + it('should not include links that are not in the chrome navLinks', async () => { + chromeNavLinks = [chromeNavLink2]; + const chromeNavNodes = formatChromeProjectNavNodes([link1, link2]); + + expect(chromeNavNodes).toEqual([ + { + id: chromeNavLink2.id, + title: link2.title, + path: [chromeNavLink2.id], + deepLink: chromeNavLink2, + }, + ]); + }); + + it('should set hidden breadcrumb for blacklisted links', async () => { + const chromeNavLinkTest = { + ...chromeNavLink1, + id: `${APP_UI_ID}:${SecurityPageName.usersEvents}`, // userEvents link is blacklisted + }; + chromeNavLinks = [chromeNavLinkTest, chromeNavLink2]; + + const chromeNavNodes = formatChromeProjectNavNodes([ + { ...link1, id: SecurityPageName.usersEvents }, + link2, + ]); + + expect(chromeNavNodes).toEqual([ + { + id: chromeNavLinkTest.id, + title: link1.title, + path: [chromeNavLinkTest.id], + deepLink: chromeNavLinkTest, + breadcrumbStatus: 'hidden', + }, + { + id: chromeNavLink2.id, + title: link2.title, + path: [chromeNavLink2.id], + deepLink: chromeNavLink2, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts similarity index 62% rename from x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts rename to x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts index 55b82759e6a02..16a676e34dd90 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts @@ -9,55 +9,20 @@ import type { ChromeProjectNavigationNode, NodeDefinition } from '@kbn/core-chro import { defaultNavigation as mlDefaultNav } from '@kbn/default-nav-ml'; import { defaultNavigation as devToolsDefaultNav } from '@kbn/default-nav-devtools'; import { SecurityPageName } from '@kbn/security-solution-navigation'; -import type { Services } from '../common/services'; -import type { ProjectNavigationLink, ProjectPageName } from './links/types'; -import { getNavLinkIdFromProjectPageName } from './links/util'; -import { ExternalPageName } from './links/constants'; +import type { Services } from '../../common/services'; +import type { ProjectNavigationLink, ProjectPageName } from '../links/types'; +import { getNavLinkIdFromProjectPageName } from '../links/util'; +import { ExternalPageName } from '../links/constants'; +import { isBreadcrumbHidden } from './utils'; -// We need to hide breadcrumbs for some pages (tabs) because they appear duplicated. -// These breadcrumbs are incorrectly processed as trailing breadcrumbs in SecuritySolution, because of `SpyRoute` architecture limitations. -// They are navLinks tree with a SecurityPageName, so they should be treated as leading breadcrumbs in ESS as well. -// TODO: Improve the breadcrumbs logic in `use_breadcrumbs_nav` to avoid this workaround. -const HIDDEN_BREADCRUMBS = new Set([ - SecurityPageName.networkDns, - SecurityPageName.networkHttp, - SecurityPageName.networkTls, - SecurityPageName.networkAnomalies, - SecurityPageName.networkEvents, - SecurityPageName.usersAuthentications, - SecurityPageName.usersAnomalies, - SecurityPageName.usersRisk, - SecurityPageName.usersEvents, - SecurityPageName.uncommonProcesses, - SecurityPageName.hostsAnomalies, - SecurityPageName.hostsEvents, - SecurityPageName.hostsRisk, - SecurityPageName.sessions, -]); - -const isBreadcrumbHidden = (id: ProjectPageName): boolean => - HIDDEN_BREADCRUMBS.has(id) || - id.startsWith('management:'); /* management sub-pages set their breadcrumbs themselves */ - -export const subscribeNavigationTree = (services: Services): void => { - const { serverless, getProjectNavLinks$ } = services; - - const formatChromeProjectNavNodes = getFormatChromeProjectNavNodes(services); - - // projectNavLinks$ updates when chrome.navLinks changes, no need to subscribe chrome.navLinks.getNavLinks$() again. - getProjectNavLinks$().subscribe((projectNavLinks) => { - const navigationTree = formatChromeProjectNavNodes(projectNavLinks); - serverless.setNavigation({ navigationTree }); - }); -}; - -// Closure to access the up to date chrome.navLinks from services +// Closure to access the "up-to-date" chrome.navLinks from services export const getFormatChromeProjectNavNodes = (services: Services) => { const formatChromeProjectNavNodes = ( projectNavLinks: ProjectNavigationLink[], path: string[] = [] ): ChromeProjectNavigationNode[] => { const { chrome } = services; + return projectNavLinks.reduce((navNodes, navLink) => { const { id, title, links } = navLink; const navLinkId = getNavLinkIdFromProjectPageName(id); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts new file mode 100644 index 0000000000000..4653756dc435c --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.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 { Observable } from 'rxjs'; +import { map } from 'rxjs'; +import type { NavigationTreeDefinition } from '@kbn/shared-ux-chrome-navigation'; +import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; +import type { LinkCategory } from '@kbn/security-solution-navigation'; +import type { Services } from '../../common/services'; +import type { ProjectNavLinks, ProjectPageName } from '../links/types'; +import { getFormatChromeProjectNavNodes } from './chrome_navigation_tree'; +import { formatNavigationTree } from './navigation_tree'; +import { CATEGORIES } from '../side_navigation/categories'; + +const projectCategories = CATEGORIES as Array>; + +/** + * This class is temporary until we can remove the chrome navigation tree and use only the formatNavigationTree + */ +export class ProjectNavigationTree { + private projectNavLinks$: ProjectNavLinks; + + constructor(private readonly services: Services) { + const { getProjectNavLinks$ } = this.services; + this.projectNavLinks$ = getProjectNavLinks$(); + } + + public getNavigationTree$(): Observable { + return this.projectNavLinks$.pipe( + map((projectNavLinks) => formatNavigationTree(projectNavLinks, projectCategories)) + ); + } + + public getChromeNavigationTree$(): Observable { + const formatChromeProjectNavNodes = getFormatChromeProjectNavNodes(this.services); + return this.projectNavLinks$.pipe( + map((projectNavLinks) => formatChromeProjectNavNodes(projectNavLinks)) + ); + } +} diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts new file mode 100644 index 0000000000000..f4971a271d7fb --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ChromeNavLink } from '@kbn/core/public'; +import { APP_UI_ID } from '@kbn/security-solution-plugin/common'; +import { SecurityPageName, LinkCategoryType } from '@kbn/security-solution-navigation'; +import { formatNavigationTree } from './navigation_tree'; +import type { ProjectNavigationLink } from '../links/types'; +import type { ExternalPageName } from '../links/constants'; +import type { GroupDefinition } from '@kbn/shared-ux-chrome-navigation'; + +const link1Id = 'link-1' as SecurityPageName; +const link2Id = 'link-2' as SecurityPageName; +const link3Id = 'externalAppId:link-1' as ExternalPageName; + +const link1: ProjectNavigationLink = { id: link1Id, title: 'link 1' }; +const link2: ProjectNavigationLink = { id: link2Id, title: 'link 2' }; +const link3: ProjectNavigationLink = { id: link3Id, title: 'link 3' }; + +const chromeNavLink1: ChromeNavLink = { + id: `${APP_UI_ID}:${link1.id}`, + title: link1.title, + href: '/link1', + url: '/link1', + baseUrl: '', +}; +const chromeNavLink2: ChromeNavLink = { + id: `${APP_UI_ID}:${link2.id}`, + title: link2.title, + href: '/link2', + url: '/link2', + baseUrl: '', +}; +const chromeNavLink3: ChromeNavLink = { + id: link3.id, + title: link3.title, + href: '/link3', + url: '/link3', + baseUrl: '', +}; + +describe('formatNavigationTree', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should format flat nav nodes', async () => { + const navigationTree = formatNavigationTree([link1]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + link: chromeNavLink1.id, + title: link1.title, + }, + ]); + }); + + it('should format nested nav nodes with categories', async () => { + const category = { + label: 'Category 1', + type: LinkCategoryType.title, + linkIds: [link1Id], + }; + const navigationTree = formatNavigationTree([link1], [category]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + title: category.label, + id: expect.any(String), + children: [ + { + link: chromeNavLink1.id, + title: link1.title, + }, + ], + }, + ]); + }); + + it('should format flat nav nodes with separator categories', async () => { + const category = { + label: 'Category 1', + type: LinkCategoryType.separator, + linkIds: [link1Id, link2Id], + }; + const navigationTree = formatNavigationTree([link1, link2], [category]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + link: chromeNavLink1.id, + title: link1.title, + }, + { + link: chromeNavLink2.id, + title: link2.title, + }, + ]); + }); + + it('should not format missing nav nodes in the category', async () => { + const category = { + label: 'Category 1', + type: LinkCategoryType.title, + linkIds: [link1Id, link2Id], + }; + const navigationTree = formatNavigationTree([link1], [category]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + title: category.label, + id: expect.any(String), + children: [ + { + link: chromeNavLink1.id, + title: link1.title, + }, + ], + }, + ]); + }); + + it('should format only nav nodes in the category', async () => { + const category = { + label: 'Category 1', + type: LinkCategoryType.title, + linkIds: [link1Id], + }; + const navigationTree = formatNavigationTree([link1, link2], [category]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + title: category.label, + id: expect.any(String), + children: [ + { + link: chromeNavLink1.id, + title: link1.title, + }, + ], + }, + ]); + }); + + it('should format external chrome nav nodes', async () => { + const navigationTree = formatNavigationTree([link3]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + link: chromeNavLink3.id, + title: link3.title, + }, + ]); + }); + + it('should set nested links', async () => { + const navigationTree = formatNavigationTree([ + { ...link1, links: [{ ...link2, links: [link3] }] }, + ]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + link: chromeNavLink1.id, + title: link1.title, + children: [ + { + link: chromeNavLink2.id, + title: link2.title, + children: [{ link: chromeNavLink3.id, title: link3.title }], + }, + ], + }, + ]); + }); + + it('should set hidden breadcrumb for blacklisted links', async () => { + const chromeNavLinkTest = { + ...chromeNavLink1, + id: `${APP_UI_ID}:${SecurityPageName.usersEvents}`, // userEvents link is blacklisted + }; + + const navigationTree = formatNavigationTree([ + { ...link1, id: SecurityPageName.usersEvents }, + link2, + ]); + const securityNode = navigationTree.body?.[0] as GroupDefinition; + + expect(securityNode?.children).toEqual([ + { + link: chromeNavLinkTest.id, + title: link1.title, + breadcrumbStatus: 'hidden', + }, + { + link: chromeNavLink2.id, + title: link2.title, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts new file mode 100644 index 0000000000000..0db77598e7032 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.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 { i18n } from '@kbn/i18n'; +import type { NavigationTreeDefinition } from '@kbn/shared-ux-chrome-navigation'; +import type { AppDeepLinkId, NodeDefinition } from '@kbn/core-chrome-browser'; +import type { NonEmptyArray } from '@kbn/shared-ux-chrome-navigation/src/ui/types'; +import type { LinkCategory } from '@kbn/security-solution-navigation'; +import { + SecurityPageName, + isSeparatorLinkCategory, + isTitleLinkCategory, +} from '@kbn/security-solution-navigation'; +import type { ProjectNavigationLink, ProjectPageName } from '../links/types'; +import { getNavLinkIdFromProjectPageName } from '../links/util'; +import { isBreadcrumbHidden } from './utils'; + +const SECURITY_TITLE = i18n.translate('xpack.securitySolutionServerless.nav.solution.title', { + defaultMessage: 'Security', +}); +const GET_STARTED_TITLE = i18n.translate('xpack.securitySolutionServerless.nav.getStarted.title', { + defaultMessage: 'Get Started', +}); +const DEV_TOOLS_TITLE = i18n.translate('xpack.securitySolutionServerless.nav.devTools.title', { + defaultMessage: 'Developer tools', +}); +const PROJECT_SETTINGS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.nav.projectSettings.title', + { + defaultMessage: 'Project settings', + } +); + +export const formatNavigationTree = ( + projectNavLinks: ProjectNavigationLink[], + categories?: Readonly>> +): NavigationTreeDefinition => ({ + body: [ + { + type: 'navGroup', + id: 'security_project_nav', + title: SECURITY_TITLE, + icon: 'logoSecurity', + breadcrumbStatus: 'hidden', + defaultIsCollapsed: false, + children: formatNodesFromLinks(projectNavLinks, categories), + }, + ], + footer: [ + { + type: 'navGroup', + id: 'getStarted', + title: GET_STARTED_TITLE, + link: getNavLinkIdFromProjectPageName(SecurityPageName.landing) as AppDeepLinkId, + icon: 'launch', + }, + { + type: 'navGroup', + id: 'devTools', + title: DEV_TOOLS_TITLE, + link: 'dev_tools', + icon: 'editorCodeBlock', + }, + { + type: 'navGroup', + id: 'project_settings_project_nav', + title: PROJECT_SETTINGS_TITLE, + icon: 'gear', + breadcrumbStatus: 'hidden', + children: [ + { + id: 'settings', + children: [ + { + link: 'management', + title: 'Management', + }, + { + link: 'integrations', + }, + { + link: 'fleet', + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', + }, + ], + }, + ], + }, + ], +}); + +const formatNodesFromLinks = ( + projectNavLinks: ProjectNavigationLink[], + parentCategories?: Readonly>> +): NonEmptyArray | undefined => { + if (projectNavLinks.length === 0) { + return undefined; + } + const nodes: NodeDefinition[] = []; + if (parentCategories?.length) { + parentCategories.forEach((category) => { + nodes.push(...formatNodesFromLinksWithCategory(projectNavLinks, category)); + }, []); + } else { + nodes.push(...formatNodesFromLinksWithoutCategory(projectNavLinks)); + } + if (nodes.length === 0) { + return undefined; + } + return nodes as NonEmptyArray; +}; + +const formatNodesFromLinksWithCategory = ( + projectNavLinks: ProjectNavigationLink[], + category: LinkCategory +): NodeDefinition[] => { + if (!category?.linkIds) { + return []; + } + if (isTitleLinkCategory(category)) { + const children = category.linkIds.reduce((acc, linkId) => { + const projectNavLink = projectNavLinks.find(({ id }) => id === linkId); + if (projectNavLink != null) { + acc.push(createNodeFromProjectNavLink(projectNavLink)); + } + return acc; + }, []); + if (children.length === 0) { + return []; + } + return [ + { + id: `category-${category.label.toLowerCase().replace(' ', '_')}`, + title: category.label, + children: children as NonEmptyArray, + }, + ]; + } else if (isSeparatorLinkCategory(category)) { + // TODO: Add separator support when implemented in the shared-ux navigation + const categoryProjectNavLinks = category.linkIds.reduce( + (acc, linkId) => { + const projectNavLink = projectNavLinks.find(({ id }) => id === linkId); + if (projectNavLink != null) { + acc.push(projectNavLink); + } + return acc; + }, + [] + ); + return formatNodesFromLinksWithoutCategory(categoryProjectNavLinks); + } + return []; +}; + +const formatNodesFromLinksWithoutCategory = (projectNavLinks: ProjectNavigationLink[]) => + projectNavLinks.map((projectNavLink) => + createNodeFromProjectNavLink(projectNavLink) + ) as NonEmptyArray; + +const createNodeFromProjectNavLink = (projectNavLink: ProjectNavigationLink): NodeDefinition => { + const { id, title, links, categories } = projectNavLink; + const link = getNavLinkIdFromProjectPageName(id); + const node: NodeDefinition = { + link: link as AppDeepLinkId, + title, + ...(isBreadcrumbHidden(id) && { breadcrumbStatus: 'hidden' }), + }; + if (links?.length) { + node.children = formatNodesFromLinks(links, categories); + } + return node; +}; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/utils.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/utils.ts new file mode 100644 index 0000000000000..3a908b9913060 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/utils.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 { SecurityPageName } from '@kbn/security-solution-navigation'; +import type { ProjectPageName } from '../links/types'; + +// We need to hide breadcrumbs for some pages (tabs) because they appear duplicated. +// These breadcrumbs are incorrectly processed as trailing breadcrumbs in SecuritySolution, because of `SpyRoute` architecture limitations. +// They are navLinks tree with a SecurityPageName, so they should be treated as leading breadcrumbs in ESS as well. +// TODO: Improve the breadcrumbs logic in `use_breadcrumbs_nav` to avoid this workaround. +const HIDDEN_BREADCRUMBS = new Set([ + SecurityPageName.networkDns, + SecurityPageName.networkHttp, + SecurityPageName.networkTls, + SecurityPageName.networkAnomalies, + SecurityPageName.networkEvents, + SecurityPageName.usersAuthentications, + SecurityPageName.usersAnomalies, + SecurityPageName.usersRisk, + SecurityPageName.usersEvents, + SecurityPageName.uncommonProcesses, + SecurityPageName.hostsAnomalies, + SecurityPageName.hostsEvents, + SecurityPageName.hostsRisk, + SecurityPageName.sessions, +]); + +export const isBreadcrumbHidden = (id: ProjectPageName): boolean => + HIDDEN_BREADCRUMBS.has(id) || + id.startsWith('management:'); /* management sub-pages set their breadcrumbs themselves */ diff --git a/x-pack/plugins/security_solution_serverless/public/plugin.ts b/x-pack/plugins/security_solution_serverless/public/plugin.ts index cbdff908b7b70..3f11cf02cd5b3 100644 --- a/x-pack/plugins/security_solution_serverless/public/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/public/plugin.ts @@ -18,9 +18,12 @@ import type { } from './types'; import { registerUpsellings } from './upselling'; import { createServices } from './common/services/create_services'; -import { configureNavigation } from './navigation'; +import { setupNavigation, startNavigation } from './navigation'; import { setRoutes } from './pages/routes'; -import { projectAppLinksSwitcher } from './navigation/links/app_links'; +import { + parseExperimentalConfigValue, + type ExperimentalFeatures, +} from '../common/experimental_features'; export class SecuritySolutionServerlessPlugin implements @@ -32,19 +35,25 @@ export class SecuritySolutionServerlessPlugin > { private config: ServerlessSecurityPublicConfig; + private experimentalFeatures: ExperimentalFeatures; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.experimentalFeatures = {} as ExperimentalFeatures; } public setup( - _core: CoreSetup, + core: CoreSetup, setupDeps: SecuritySolutionServerlessPluginSetupDeps ): SecuritySolutionServerlessPluginSetup { const { securitySolution } = setupDeps; - securitySolution.setAppLinksSwitcher(projectAppLinksSwitcher); - securitySolution.setDataQualityPanelConfig({ isILMAvailable: false }); + this.experimentalFeatures = parseExperimentalConfigValue( + this.config.enableExperimental, + securitySolution.experimentalFeatures + ).features; + + setupNavigation(core, setupDeps, this.experimentalFeatures); return {}; } @@ -55,16 +64,16 @@ export class SecuritySolutionServerlessPlugin const { securitySolution } = startDeps; const { productTypes } = this.config; - const services = createServices(core, startDeps); + const services = createServices(core, startDeps, this.experimentalFeatures); - registerUpsellings(securitySolution.getUpselling(), this.config.productTypes, services); + registerUpsellings(securitySolution.getUpselling(), productTypes, services); securitySolution.setComponents({ getStarted: getSecurityGetStartedComponent(services, productTypes), dashboardsLandingCallout: getDashboardsLandingCallout(services), }); - configureNavigation(services, this.config); + startNavigation(services, this.config); setRoutes(services); return {}; diff --git a/x-pack/plugins/security_solution_serverless/public/types.ts b/x-pack/plugins/security_solution_serverless/public/types.ts index 51d335d5fd3cf..cb34701fe6feb 100644 --- a/x-pack/plugins/security_solution_serverless/public/types.ts +++ b/x-pack/plugins/security_solution_serverless/public/types.ts @@ -13,7 +13,7 @@ import type { import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; import type { CloudStart } from '@kbn/cloud-plugin/public'; -import type { SecurityProductTypes, DeveloperConfig } from '../common/config'; +import type { ServerlessSecurityConfigSchema } from '../common/config'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecuritySolutionServerlessPluginSetup {} @@ -36,7 +36,7 @@ export interface SecuritySolutionServerlessPluginStartDeps { cloud: CloudStart; } -export interface ServerlessSecurityPublicConfig { - productTypes: SecurityProductTypes; - developer: DeveloperConfig; -} +export type ServerlessSecurityPublicConfig = Pick< + ServerlessSecurityConfigSchema, + 'productTypes' | 'developer' | 'enableExperimental' +>; diff --git a/x-pack/plugins/security_solution_serverless/server/app_features/index.ts b/x-pack/plugins/security_solution_serverless/server/app_features/index.ts index 95c5bea8f05df..52569aa016da4 100644 --- a/x-pack/plugins/security_solution_serverless/server/app_features/index.ts +++ b/x-pack/plugins/security_solution_serverless/server/app_features/index.ts @@ -7,15 +7,20 @@ import type { AppFeatureKeys } from '@kbn/security-solution-features'; import type { AppFeaturesConfigurator } from '@kbn/security-solution-plugin/server/lib/app_features_service/types'; +import type { ServerlessSecurityConfig } from '../config'; import { getCasesAppFeaturesConfigurator } from './cases_app_features_config'; import { getSecurityAppFeaturesConfigurator } from './security_app_features_config'; import { getSecurityAssistantAppFeaturesConfigurator } from './security_assistant_app_features_config'; export const getProductAppFeaturesConfigurator = ( - enabledAppFeatureKeys: AppFeatureKeys + enabledAppFeatureKeys: AppFeatureKeys, + config: ServerlessSecurityConfig ): AppFeaturesConfigurator => { return { - security: getSecurityAppFeaturesConfigurator(enabledAppFeatureKeys), + security: getSecurityAppFeaturesConfigurator( + enabledAppFeatureKeys, + config.experimentalFeatures + ), cases: getCasesAppFeaturesConfigurator(enabledAppFeatureKeys), securityAssistant: getSecurityAssistantAppFeaturesConfigurator(enabledAppFeatureKeys), }; diff --git a/x-pack/plugins/security_solution_serverless/server/app_features/security_app_features_config.ts b/x-pack/plugins/security_solution_serverless/server/app_features/security_app_features_config.ts index a18e9c39d2f5a..9c85e4eb3290d 100644 --- a/x-pack/plugins/security_solution_serverless/server/app_features/security_app_features_config.ts +++ b/x-pack/plugins/security_solution_serverless/server/app_features/security_app_features_config.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { ExperimentalFeatures } from '@kbn/security-solution-plugin/common'; import type { AppFeatureKeys, AppFeatureKibanaConfig, @@ -15,12 +14,14 @@ import { createEnabledAppFeaturesConfigMap, } from '@kbn/security-solution-features/config'; import { AppFeatureSecurityKey, SecuritySubFeatureId } from '@kbn/security-solution-features/keys'; +import type { ExperimentalFeatures } from '../../common/experimental_features'; export const getSecurityAppFeaturesConfigurator = - (enabledAppFeatureKeys: AppFeatureKeys) => ( + enabledAppFeatureKeys: AppFeatureKeys, _: ExperimentalFeatures // currently un-used, but left here as a convenience for possible future use - ): AppFeaturesSecurityConfig => { + ) => + (): AppFeaturesSecurityConfig => { return createEnabledAppFeaturesConfigMap(securityAppFeaturesConfig, enabledAppFeatureKeys); }; diff --git a/x-pack/plugins/security_solution_serverless/server/config.ts b/x-pack/plugins/security_solution_serverless/server/config.ts index 0ea82d1d70004..551fd5976a761 100644 --- a/x-pack/plugins/security_solution_serverless/server/config.ts +++ b/x-pack/plugins/security_solution_serverless/server/config.ts @@ -6,18 +6,37 @@ */ import { schema, type TypeOf } from '@kbn/config-schema'; -import type { PluginConfigDescriptor } from '@kbn/core/server'; +import type { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server'; +import type { SecuritySolutionPluginSetup } from '@kbn/security-solution-plugin/server/plugin_contract'; import { developerConfigSchema, productTypes } from '../common/config'; +import type { ExperimentalFeatures } from '../common/experimental_features'; +import { parseExperimentalConfigValue } from '../common/experimental_features'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), developer: developerConfigSchema, productTypes, + /** + * For internal use. A list of string values (comma delimited) that will enable experimental + * type of functionality that is not yet released. Valid values for this settings need to + * be defined in: + * `x-pack/plugins/security_solution_serverless/common/experimental_features.ts` + * under the `allowedExperimentalValues` object + * + * @example + * xpack.securitySolutionServerless.enableExperimental: + * - someCrazyServerlessFeature + * - someEvenCrazierServerlessFeature + */ + enableExperimental: schema.arrayOf(schema.string(), { + defaultValue: () => [], + }), }); -export type ServerlessSecurityConfig = TypeOf; +export type ServerlessSecuritySchema = TypeOf; -export const config: PluginConfigDescriptor = { +export const config: PluginConfigDescriptor = { exposeToBrowser: { + enableExperimental: true, productTypes: true, developer: true, }, @@ -30,3 +49,44 @@ export const config: PluginConfigDescriptor = { ), ], }; + +export type ServerlessSecurityConfig = Omit & { + experimentalFeatures: ExperimentalFeatures; +}; + +export const createConfig = ( + context: PluginInitializerContext, + securitySolution: SecuritySolutionPluginSetup +): ServerlessSecurityConfig => { + const { enableExperimental, ...pluginConfig } = context.config.get(); + const logger = context.logger.get('config'); + + const { + invalid, + duplicated, + features: experimentalFeatures, + } = parseExperimentalConfigValue(enableExperimental, securitySolution.experimentalFeatures); + + if (invalid.length) { + logger.warn(`Unsupported "xpack.securitySolutionServerless.enableExperimental" values detected. +The following configuration values are not supported and should be removed from the configuration: + + xpack.securitySolutionServerless.enableExperimental: +${invalid.map((key) => ` - ${key}`).join('\n')} +`); + } + + if (duplicated.length) { + logger.warn(`Duplicated "xpack.securitySolutionServerless.enableExperimental" values detected. +The following configuration values are should only be defined using the generic "xpack.securitySolution.enableExperimental": + + xpack.securitySolutionServerless.enableExperimental: +${duplicated.map((key) => ` - ${key}`).join('\n')} +`); + } + + return { + ...pluginConfig, + experimentalFeatures, + }; +}; diff --git a/x-pack/plugins/security_solution_serverless/server/plugin.ts b/x-pack/plugins/security_solution_serverless/server/plugin.ts index e960d6743942c..fc77d2829d9f4 100644 --- a/x-pack/plugins/security_solution_serverless/server/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/server/plugin.ts @@ -17,6 +17,7 @@ import { SECURITY_PROJECT_SETTINGS } from '@kbn/serverless-security-settings'; import { getProductAppFeatures } from '../common/pli/pli_features'; import type { ServerlessSecurityConfig } from './config'; +import { createConfig } from './config'; import type { SecuritySolutionServerlessPluginSetup, SecuritySolutionServerlessPluginStart, @@ -52,15 +53,18 @@ export class SecuritySolutionServerlessPlugin } public setup(coreSetup: CoreSetup, pluginsSetup: SecuritySolutionServerlessPluginSetupDeps) { + this.config = createConfig(this.initializerContext, pluginsSetup.securitySolution); + // securitySolutionEss plugin should always be disabled when securitySolutionServerless is enabled. // This check is an additional layer of security to prevent double registrations when - // `plugins.forceEnableAllPlugins` flag is enabled). + // `plugins.forceEnableAllPlugins` flag is enabled. Should never happen in real scenarios. const shouldRegister = pluginsSetup.securitySolutionEss == null; if (shouldRegister) { const productTypesStr = JSON.stringify(this.config.productTypes, null, 2); this.logger.info(`Security Solution running with product types:\n${productTypesStr}`); const appFeaturesConfigurator = getProductAppFeaturesConfigurator( - getProductAppFeatures(this.config.productTypes) + getProductAppFeatures(this.config.productTypes), + this.config ); pluginsSetup.securitySolution.setAppFeaturesConfigurator(appFeaturesConfigurator); } @@ -97,9 +101,9 @@ export class SecuritySolutionServerlessPlugin return {}; } - public start(_coreStart: CoreStart, pluginsSetup: SecuritySolutionServerlessPluginStartDeps) { - const internalESClient = _coreStart.elasticsearch.client.asInternalUser; - const internalSOClient = _coreStart.savedObjects.createInternalRepository(); + public start(coreStart: CoreStart, pluginsSetup: SecuritySolutionServerlessPluginStartDeps) { + const internalESClient = coreStart.elasticsearch.client.asInternalUser; + const internalSOClient = coreStart.savedObjects.createInternalRepository(); this.cloudSecurityUsageReportingTask?.start({ taskManager: pluginsSetup.taskManager, diff --git a/x-pack/plugins/security_solution_serverless/tsconfig.json b/x-pack/plugins/security_solution_serverless/tsconfig.json index 2aa2b979180f5..77481caa489fb 100644 --- a/x-pack/plugins/security_solution_serverless/tsconfig.json +++ b/x-pack/plugins/security_solution_serverless/tsconfig.json @@ -43,6 +43,7 @@ "@kbn/core-elasticsearch-server", "@kbn/usage-collection-plugin", "@kbn/cloud-defend-plugin", - "@kbn/core-logging-server-mocks" + "@kbn/core-logging-server-mocks", + "@kbn/shared-ux-chrome-navigation" ] }