diff --git a/src/platform/packages/shared/deeplinks/security/deep_links.ts b/src/platform/packages/shared/deeplinks/security/deep_links.ts index dd18c50add694..46fe42c2588d1 100644 --- a/src/platform/packages/shared/deeplinks/security/deep_links.ts +++ b/src/platform/packages/shared/deeplinks/security/deep_links.ts @@ -12,6 +12,7 @@ export enum SecurityPageName { alerts = 'alerts', attacks = 'attacks', aiValue = 'ai_value', + artifacts = 'artifacts', assetInventory = 'asset_inventory', attackDiscovery = 'attack_discovery', blocklist = 'blocklist', diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 6cfbe121351cb..e92dae37fd59b 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -32258,7 +32258,6 @@ "xpack.securitySolution.appLinks.actionHistoryDescription": "Zeigen Sie den Verlauf der auf Hosts ausgeführten Reaktionsmaßnahmen an.", "xpack.securitySolution.appLinks.alerts": "Alerts", "xpack.securitySolution.appLinks.attackDiscovery": "Angriffserkennung", - "xpack.securitySolution.appLinks.blocklistDescription": "Schließen Sie unerwünschte Anwendungen von der Ausführung auf Ihren Hosts aus.", "xpack.securitySolution.appLinks.category.discover": "Discover", "xpack.securitySolution.appLinks.category.endpoints": "Endpoints", "xpack.securitySolution.appLinks.category.entityAnalytics": "Entity Analytics", @@ -32275,12 +32274,10 @@ "xpack.securitySolution.appLinks.ecsDataQualityDashboardDescription": "Überprüfen Sie die Index-Mappings und Werte auf Kompatibilität mit dem Elastic Common Schema (ECS).", "xpack.securitySolution.appLinks.endpointsDescription": "Hosts, auf denen Elastic Defend läuft.", "xpack.securitySolution.appLinks.entityAnalyticsDescription": "Entitätsanalysen, Anomalien und Bedrohungen, um den Monitoring-Bereich einzugrenzen.", - "xpack.securitySolution.appLinks.eventFiltersDescription": "Verhindern Sie, dass Ereignisse mit hohem Volumen oder unerwünschte Ereignisse in Elasticsearch geschrieben werden.", "xpack.securitySolution.appLinks.exceptions": "Ausnahmelisten", "xpack.securitySolution.appLinks.exceptionsDescription": "Erstellen und verwalten Sie gemeinsame Ausnahmelisten, um die Erstellung unerwünschter Alarme zu verhindern.", "xpack.securitySolution.appLinks.explore": "Erkunden", "xpack.securitySolution.appLinks.getStarted": "Erste Schritte", - "xpack.securitySolution.appLinks.hostIsolationDescription": "Erlauben Sie isolierten Hosts, mit bestimmten IP-Adressen zu kommunizieren.", "xpack.securitySolution.appLinks.hosts": "Hosts", "xpack.securitySolution.appLinks.hosts.allHosts": "Alle Hosts", "xpack.securitySolution.appLinks.hosts.anomalies": "Anomalien", @@ -32305,7 +32302,6 @@ "xpack.securitySolution.appLinks.rulesDescription": "Erstellen und verwalten Sie Erkennungsregeln für die Bedrohungserkennung und -Monitoring.", "xpack.securitySolution.appLinks.timeline.templates": "Vorlagen", "xpack.securitySolution.appLinks.timelines": "Zeitleisten", - "xpack.securitySolution.appLinks.trustedApplicationsDescription": "Verbessern Sie die Leistung oder mindern Sie Konflikte mit anderen Anwendungen, die auf Ihren Hosts ausgeführt werden.", "xpack.securitySolution.appLinks.users": "Nutzer", "xpack.securitySolution.appLinks.users.allUsers": "Alle Nutzer", "xpack.securitySolution.appLinks.users.anomalies": "Anomalien", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 3ab6e513d03a3..49a4b676d0d1f 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -32601,7 +32601,6 @@ "xpack.securitySolution.appLinks.actionHistoryDescription": "Affichez l'historique des actions de réponse effectuées sur les hôtes.", "xpack.securitySolution.appLinks.alerts": "Alertes", "xpack.securitySolution.appLinks.attackDiscovery": "Attack discovery", - "xpack.securitySolution.appLinks.blocklistDescription": "Excluez les applications non souhaitées de l'exécution sur vos hôtes.", "xpack.securitySolution.appLinks.category.cloudSecurity": "Sécurité du cloud", "xpack.securitySolution.appLinks.category.discover": "Discover", "xpack.securitySolution.appLinks.category.endpoints": "Points de terminaison", @@ -32620,12 +32619,10 @@ "xpack.securitySolution.appLinks.ecsDataQualityDashboardDescription": "Vérifiez la compatibilité des mappings et des valeurs d'index avec Elastic Common Schema (ECS)", "xpack.securitySolution.appLinks.endpointsDescription": "Hôtes exécutant Elastic Defend.", "xpack.securitySolution.appLinks.entityAnalyticsDescription": "Analyse d'entités, anomalies et menaces pour limiter la surface de monitoring.", - "xpack.securitySolution.appLinks.eventFiltersDescription": "Excluez les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", "xpack.securitySolution.appLinks.exceptions": "Listes d'exceptions", "xpack.securitySolution.appLinks.exceptionsDescription": "Créez et gérez des listes d'exceptions partagées pour empêcher la création d'alertes non souhaitées.", "xpack.securitySolution.appLinks.explore": "Explorer", "xpack.securitySolution.appLinks.getStarted": "Premiers pas", - "xpack.securitySolution.appLinks.hostIsolationDescription": "Autorisez les hôtes isolés à communiquer avec des IP spécifiques.", "xpack.securitySolution.appLinks.hosts": "Hôtes", "xpack.securitySolution.appLinks.hosts.allHosts": "Tous les hôtes", "xpack.securitySolution.appLinks.hosts.anomalies": "Anomalies", @@ -32651,7 +32648,6 @@ "xpack.securitySolution.appLinks.rulesDescription": "Créez et gérez les règles de détection pour la détection et le monitoring des menaces.", "xpack.securitySolution.appLinks.timeline.templates": "Modèles", "xpack.securitySolution.appLinks.timelines": "Chronologies", - "xpack.securitySolution.appLinks.trustedApplicationsDescription": "Améliorez les performances ou réduisez les conflits avec d'autres applications en cours d'exécution sur vos hôtes.", "xpack.securitySolution.appLinks.users": "Utilisateurs", "xpack.securitySolution.appLinks.users.allUsers": "Tous les utilisateurs", "xpack.securitySolution.appLinks.users.anomalies": "Anomalies", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 0c6eed0b6803f..fa5e9416efdb7 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -32655,7 +32655,6 @@ "xpack.securitySolution.appLinks.actionHistoryDescription": "ホストで実行された対応アクションの履歴を表示します。", "xpack.securitySolution.appLinks.alerts": "アラート", "xpack.securitySolution.appLinks.attackDiscovery": "Attack discovery", - "xpack.securitySolution.appLinks.blocklistDescription": "不要なアプリケーションがホストで実行されないようにします。", "xpack.securitySolution.appLinks.category.cloudSecurity": "クラウドセキュリティ", "xpack.securitySolution.appLinks.category.discover": "Discover", "xpack.securitySolution.appLinks.category.endpoints": "エンドポイント", @@ -32674,12 +32673,10 @@ "xpack.securitySolution.appLinks.ecsDataQualityDashboardDescription": "Elastic Common Schema(ECS)との互換性に関してインデックスマッピングと値を確認", "xpack.securitySolution.appLinks.endpointsDescription": "Elastic Defendを実行しているホスト。", "xpack.securitySolution.appLinks.entityAnalyticsDescription": "監視面の分野を絞り込むエンティティ分析、異常、脅威。", - "xpack.securitySolution.appLinks.eventFiltersDescription": "大量のイベントや不要なイベントがElasticsearchに書き込まれないようにします。", "xpack.securitySolution.appLinks.exceptions": "例外リスト", "xpack.securitySolution.appLinks.exceptionsDescription": "不要なアラートの生成を防止するために、共有例外リストを作成して管理します。", "xpack.securitySolution.appLinks.explore": "探索", "xpack.securitySolution.appLinks.getStarted": "はじめて使う", - "xpack.securitySolution.appLinks.hostIsolationDescription": "分離されたホストが特定のIPと通信することを許可します。", "xpack.securitySolution.appLinks.hosts": "ホスト", "xpack.securitySolution.appLinks.hosts.allHosts": "すべてのホスト", "xpack.securitySolution.appLinks.hosts.anomalies": "異常", @@ -32705,7 +32702,6 @@ "xpack.securitySolution.appLinks.rulesDescription": "脅威の検出と監視のための検出ルールの作成および管理します。", "xpack.securitySolution.appLinks.timeline.templates": "テンプレート", "xpack.securitySolution.appLinks.timelines": "タイムライン", - "xpack.securitySolution.appLinks.trustedApplicationsDescription": "パフォーマンスを改善したり、ホストで実行されている他のアプリケーションとの競合を解消したりします。", "xpack.securitySolution.appLinks.users": "ユーザー", "xpack.securitySolution.appLinks.users.allUsers": "すべてのユーザー", "xpack.securitySolution.appLinks.users.anomalies": "異常", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 15b113f699d65..173affcd8991e 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -32638,7 +32638,6 @@ "xpack.securitySolution.appLinks.actionHistoryDescription": "查看在主机上执行的响应操作的历史记录。", "xpack.securitySolution.appLinks.alerts": "告警", "xpack.securitySolution.appLinks.attackDiscovery": "Attack Discovery", - "xpack.securitySolution.appLinks.blocklistDescription": "阻止不需要的应用程序在您的主机上运行。", "xpack.securitySolution.appLinks.category.cloudSecurity": "云安全", "xpack.securitySolution.appLinks.category.discover": "发现", "xpack.securitySolution.appLinks.category.endpoints": "终端", @@ -32657,12 +32656,10 @@ "xpack.securitySolution.appLinks.ecsDataQualityDashboardDescription": "检查索引映射和值以了解与 Elastic Common Schema (ECS) 的兼容性", "xpack.securitySolution.appLinks.endpointsDescription": "运行 Elastic Defend 的主机。", "xpack.securitySolution.appLinks.entityAnalyticsDescription": "用于缩小监测表面积的实体分析、异常和威胁。", - "xpack.securitySolution.appLinks.eventFiltersDescription": "阻止将高数目或非预期事件写入到 Elasticsearch。", "xpack.securitySolution.appLinks.exceptions": "例外列表", "xpack.securitySolution.appLinks.exceptionsDescription": "创建并管理共享例外列表以避免创建非预期告警。", "xpack.securitySolution.appLinks.explore": "浏览", "xpack.securitySolution.appLinks.getStarted": "入门", - "xpack.securitySolution.appLinks.hostIsolationDescription": "允许隔离的主机与特定 IP 通信。", "xpack.securitySolution.appLinks.hosts": "主机", "xpack.securitySolution.appLinks.hosts.allHosts": "所有主机", "xpack.securitySolution.appLinks.hosts.anomalies": "异常", @@ -32688,7 +32685,6 @@ "xpack.securitySolution.appLinks.rulesDescription": "创建和管理检测规则以用于威胁检测和监测。", "xpack.securitySolution.appLinks.timeline.templates": "模板", "xpack.securitySolution.appLinks.timelines": "时间线", - "xpack.securitySolution.appLinks.trustedApplicationsDescription": "提高性能或缓解与主机上运行的其他应用程序的冲突。", "xpack.securitySolution.appLinks.users": "用户", "xpack.securitySolution.appLinks.users.allUsers": "所有用户", "xpack.securitySolution.appLinks.users.anomalies": "异常", diff --git a/x-pack/solutions/security/packages/navigation/src/i18n_strings.ts b/x-pack/solutions/security/packages/navigation/src/i18n_strings.ts index 727bf787b5a53..7f035cfcbb982 100644 --- a/x-pack/solutions/security/packages/navigation/src/i18n_strings.ts +++ b/x-pack/solutions/security/packages/navigation/src/i18n_strings.ts @@ -64,6 +64,11 @@ export const i18nStrings = { defaultMessage: 'Endpoints', }), }, + artifacts: { + title: i18n.translate('securitySolutionPackages.navLinks.assets.artifacts', { + defaultMessage: 'Artifacts', + }), + }, integrationsCallout: { title: i18n.translate('securitySolutionPackages.navLinks.assets.integrationsCallout.title', { defaultMessage: 'Integrations', diff --git a/x-pack/solutions/security/packages/navigation/src/navigation_tree/assets_navigation_tree.ts b/x-pack/solutions/security/packages/navigation/src/navigation_tree/assets_navigation_tree.ts index c05e5c46afa5b..6e34151b62ff0 100644 --- a/x-pack/solutions/security/packages/navigation/src/navigation_tree/assets_navigation_tree.ts +++ b/x-pack/solutions/security/packages/navigation/src/navigation_tree/assets_navigation_tree.ts @@ -12,83 +12,81 @@ import { SecurityLinkGroup } from '../link_groups'; import { securityLink } from '../links'; import { i18nStrings } from '../i18n_strings'; -export const createAssetsNavigationTree = (core: CoreStart): NodeDefinition => ({ - id: SecurityGroupName.assets, - icon: 'display', - title: SecurityLinkGroup[SecurityGroupName.assets].title, - renderAs: 'panelOpener', - children: [ - { - link: 'fleet', - title: i18nStrings.assets.fleet.title, - children: [ - { - link: 'fleet:agents', - }, - { - link: 'fleet:policies', - title: i18nStrings.assets.fleet.policies, - }, - { - link: 'fleet:enrollment_tokens', - }, - { - link: 'fleet:uninstall_tokens', - }, - { - link: 'fleet:data_streams', - }, - { - link: 'fleet:settings', - }, - ], - }, - { - id: SecurityPageName.endpoints, - title: i18nStrings.assets.endpoints.title, - children: [ - { - id: SecurityPageName.endpoints, - link: securityLink(SecurityPageName.endpoints), - breadcrumbStatus: 'hidden', - }, - { - id: SecurityPageName.policies, - link: securityLink(SecurityPageName.policies), - }, - { - id: SecurityPageName.trustedApps, - link: securityLink(SecurityPageName.trustedApps), - }, - { - id: SecurityPageName.trustedDevices, - link: securityLink(SecurityPageName.trustedDevices), - }, - { - id: SecurityPageName.eventFilters, - link: securityLink(SecurityPageName.eventFilters), - }, - { - id: SecurityPageName.hostIsolationExceptions, - link: securityLink(SecurityPageName.hostIsolationExceptions), - }, - { - id: SecurityPageName.blocklist, - link: securityLink(SecurityPageName.blocklist), - }, - { - id: SecurityPageName.endpointExceptions, - link: securityLink(SecurityPageName.endpointExceptions), - }, - { - id: SecurityPageName.responseActionsHistory, - link: securityLink(SecurityPageName.responseActionsHistory), - }, - { - id: SecurityPageName.scriptLibrary, - link: securityLink(SecurityPageName.scriptLibrary), - }, - ], - }, - ], -}); +// All artifact tab paths under the security management section. +// Used to keep the Artifacts nav node active regardless of which tab is open, +// since the deep link only points to the first allowed tab path. +const SECURITY_MANAGEMENT_PATH = '/app/security/administration'; +const ARTIFACT_TAB_PATHS = [ + `${SECURITY_MANAGEMENT_PATH}/trusted_apps`, + `${SECURITY_MANAGEMENT_PATH}/trusted_devices`, + `${SECURITY_MANAGEMENT_PATH}/event_filters`, + `${SECURITY_MANAGEMENT_PATH}/host_isolation_exceptions`, + `${SECURITY_MANAGEMENT_PATH}/blocklist`, + `${SECURITY_MANAGEMENT_PATH}/endpoint_exceptions`, +] as const; + +export const createAssetsNavigationTree = (_core: CoreStart): NodeDefinition => { + return { + id: SecurityGroupName.assets, + icon: 'display', + title: SecurityLinkGroup[SecurityGroupName.assets].title, + renderAs: 'panelOpener', + children: [ + { + link: 'fleet', + title: i18nStrings.assets.fleet.title, + children: [ + { + link: 'fleet:agents', + }, + { + link: 'fleet:policies', + title: i18nStrings.assets.fleet.policies, + }, + { + link: 'fleet:enrollment_tokens', + }, + { + link: 'fleet:uninstall_tokens', + }, + { + link: 'fleet:data_streams', + }, + { + link: 'fleet:settings', + }, + ], + }, + { + id: SecurityPageName.endpoints, + title: i18nStrings.assets.endpoints.title, + children: [ + { + id: SecurityPageName.endpoints, + link: securityLink(SecurityPageName.endpoints), + breadcrumbStatus: 'hidden', + }, + { + id: SecurityPageName.policies, + link: securityLink(SecurityPageName.policies), + }, + { + id: SecurityPageName.artifacts, + title: i18nStrings.assets.artifacts.title, + link: securityLink(SecurityPageName.artifacts), + getIsActive: ({ pathNameSerialized, prepend }) => + ARTIFACT_TAB_PATHS.some((path) => pathNameSerialized.startsWith(prepend(path))), + }, + { + id: SecurityPageName.responseActionsHistory, + link: securityLink(SecurityPageName.responseActionsHistory), + }, + { + id: SecurityPageName.scriptLibrary, + link: securityLink(SecurityPageName.scriptLibrary), + }, + ], + }, + ], + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts index 498ea7c08392a..1820b626128b4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts @@ -26,6 +26,7 @@ import { links as managementLinks, getManagementFilteredLinks } from '../../mana import { exploreLinks } from '../../explore/links'; import { onboardingLinks } from '../../onboarding/links'; import { findingsLinks } from '../../cloud_security_posture/links'; +import type { ExperimentalFeatures } from '../../../common/experimental_features'; import type { StartPlugins } from '../../types'; import { dashboardsLinks } from '../../dashboards/links'; import { entityAnalyticsLinks } from '../../entity_analytics/links'; @@ -53,9 +54,14 @@ export const appLinks: AppLinkItems = Object.freeze([ export const getFilteredLinks = async ( core: CoreStart, - plugins: StartPlugins + plugins: StartPlugins, + experimentalFeatures: ExperimentalFeatures ): Promise => { - const managementFilteredLinks = await getManagementFilteredLinks(core, plugins); + const managementFilteredLinks = await getManagementFilteredLinks( + core, + plugins, + experimentalFeatures + ); const chatExperience$ = core.uiSettings.get$( AI_CHAT_EXPERIENCE_TYPE, diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/links/get_filtered_links.test.ts b/x-pack/solutions/security/plugins/security_solution/public/app/links/get_filtered_links.test.ts index 4c737899375c3..e4f755628b167 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/links/get_filtered_links.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/links/get_filtered_links.test.ts @@ -18,6 +18,7 @@ import { getManagementFilteredLinks } from '../../management/links'; import { SecurityPageName } from '@kbn/security-solution-navigation'; import { of } from 'rxjs'; import { AIChatExperience } from '@kbn/ai-assistant-common'; +import { allowedExperimentalValues } from '../../../common/experimental_features'; const mockGetManagementFilteredLinks = getManagementFilteredLinks as jest.MockedFunction< typeof getManagementFilteredLinks @@ -34,6 +35,7 @@ const createMockLinkItem = (overrides: Partial = {}): LinkItem => ({ describe('getFilteredLinks', () => { const mockCore = createCoreStartMock(); const mockPlugins = {} as StartPlugins; + const mockExperimentalFeatures = { ...allowedExperimentalValues }; const mockManagementLinks = createMockLinkItem({ id: SecurityPageName.administration, title: 'Management', @@ -49,17 +51,21 @@ describe('getFilteredLinks', () => { it('returns filtered links including AI Value links', async () => { mockGetManagementFilteredLinks.mockResolvedValue(mockManagementLinks); - const result = await getFilteredLinks(mockCore, mockPlugins); + const result = await getFilteredLinks(mockCore, mockPlugins, mockExperimentalFeatures); expect(result).toContainEqual(expect.objectContaining({ id: SecurityPageName.aiValue })); expect(result).toContainEqual(mockManagementLinks); - expect(mockGetManagementFilteredLinks).toHaveBeenCalledWith(mockCore, mockPlugins); + expect(mockGetManagementFilteredLinks).toHaveBeenCalledWith( + mockCore, + mockPlugins, + mockExperimentalFeatures + ); }); it('includes all base links in the result', async () => { mockGetManagementFilteredLinks.mockResolvedValue(mockManagementLinks); - const result = await getFilteredLinks(mockCore, mockPlugins); + const result = await getFilteredLinks(mockCore, mockPlugins, mockExperimentalFeatures); // Check that base links are included by checking the result has expected length expect(result.length).toBeGreaterThan(10); @@ -76,7 +82,7 @@ describe('getFilteredLinks', () => { it('returns a frozen array', async () => { mockGetManagementFilteredLinks.mockResolvedValue(mockManagementLinks); - const result = await getFilteredLinks(mockCore, mockPlugins); + const result = await getFilteredLinks(mockCore, mockPlugins, mockExperimentalFeatures); expect(Object.isFrozen(result)).toBe(true); }); @@ -84,10 +90,14 @@ describe('getFilteredLinks', () => { it('calls management filter function with correct parameters', async () => { mockGetManagementFilteredLinks.mockResolvedValue(mockManagementLinks); - await getFilteredLinks(mockCore, mockPlugins); + await getFilteredLinks(mockCore, mockPlugins, mockExperimentalFeatures); expect(mockGetManagementFilteredLinks).toHaveBeenCalledTimes(1); - expect(mockGetManagementFilteredLinks).toHaveBeenCalledWith(mockCore, mockPlugins); + expect(mockGetManagementFilteredLinks).toHaveBeenCalledWith( + mockCore, + mockPlugins, + mockExperimentalFeatures + ); }); describe('`securitySolution:enableAlertsAndAttacksAlignment` setting', () => { @@ -95,7 +105,7 @@ describe('getFilteredLinks', () => { mockCore.uiSettings.get.mockReturnValue(false); mockGetManagementFilteredLinks.mockResolvedValue(mockManagementLinks); - const result = await getFilteredLinks(mockCore, mockPlugins); + const result = await getFilteredLinks(mockCore, mockPlugins, mockExperimentalFeatures); // Check that base links are included by checking the result has expected length expect(result.length).toBeGreaterThan(10); @@ -114,7 +124,7 @@ describe('getFilteredLinks', () => { mockCore.uiSettings.get.mockReturnValue(true); mockGetManagementFilteredLinks.mockResolvedValue(mockManagementLinks); - const result = await getFilteredLinks(mockCore, mockPlugins); + const result = await getFilteredLinks(mockCore, mockPlugins, mockExperimentalFeatures); // Check that base links are included by checking the result has expected length expect(result.length).toBeGreaterThan(10); diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts index cd8aca2414785..c76c35cf6b3eb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts @@ -191,6 +191,10 @@ export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.block defaultMessage: 'Blocklist', }); +export const ARTIFACTS = i18n.translate('xpack.securitySolution.navigation.artifacts', { + defaultMessage: 'Artifacts', +}); + export const RESPONSE_ACTIONS_HISTORY = i18n.translate( 'xpack.securitySolution.navigation.responseActionsHistory', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/icons/artifacts.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/icons/artifacts.tsx new file mode 100644 index 0000000000000..1ae9070d87e24 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/icons/artifacts.tsx @@ -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 type { SVGProps } from 'react'; +import React from 'react'; +import { useKibanaIsDarkMode } from '@kbn/react-kibana-context-theme'; + +export const IconArtifacts: React.FC> = ({ ...props }) => { + const isDarkMode = useKibanaIsDarkMode(); + + const light = ( + + + + + + + + + + + + + + ); + + // Replace inner content below with dark-theme SVG (use unique ids, e.g. artifacts-dark-* in defs). + const dark = ( + + + + + + + + + + + + + + ); + + return isDarkMode ? dark : light; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index de050df05acf4..51cc1decc08b0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -6,9 +6,11 @@ */ import { waitFor, renderHook } from '@testing-library/react'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; import { useUserPrivileges } from '../../components/user_privileges'; import { useShowTimeline } from './use_show_timeline'; +import { EVENT_FILTERS_PATH, TRUSTED_APPS_PATH } from '../../../../common/constants'; import { TestProviders } from '../../mock'; import { hasAccessToSecuritySolution } from '../../../helpers_access'; import type { LinkInfo } from '../../links'; @@ -86,6 +88,20 @@ describe('use show timeline', () => { const { result } = renderUseShowTimeline(); await waitFor(() => expect(result.current).toEqual([false])); }); + + it('hides timeline on artifact tab routes when link path targets a different tab', async () => { + mockUseNormalizedAppLinks.mockReturnValueOnce([ + { + id: SecurityPageName.artifacts, + path: EVENT_FILTERS_PATH, + hideTimeline: true, + }, + ] as LinkInfo[]); + mockUseLocation.mockReturnValueOnce({ pathname: TRUSTED_APPS_PATH }); + const { result } = renderUseShowTimeline(); + await waitFor(() => expect(result.current).toEqual([false])); + }); + it('hides timeline for users without timeline access', async () => { mockUseUserPrivileges.mockReturnValue({ timelinePrivileges: { read: false } }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts index 4babfcd2f26fe..6cea58c4305ab 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts @@ -7,7 +7,9 @@ import { useCallback, useMemo } from 'react'; import { matchPath } from 'react-router-dom'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { ARTIFACT_MANAGEMENT_TAB_ROUTING_PATHS } from '../../../management/common/constants'; import type { NormalizedLink } from '../../links'; import { useNormalizedAppLinks } from '../../links/links_hooks'; import { useKibana } from '../../lib/kibana'; @@ -19,7 +21,11 @@ const useHiddenTimelineRoutes = () => { () => Object.values(normalizedLinks).reduce((acc: string[], link: NormalizedLink) => { if (link.hideTimeline) { - acc.push(link.path); + if (link.id === SecurityPageName.artifacts) { + acc.push(...ARTIFACT_MANAGEMENT_TAB_ROUTING_PATHS); + } else { + acc.push(link.path); + } } return acc; }, []), diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/common/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/management/common/constants.ts index 6cec9c86c8c1f..84aaefc204e2d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/common/constants.ts @@ -32,6 +32,16 @@ export const MANAGEMENT_ROUTING_BLOCKLIST_PATH = `${MANAGEMENT_PATH}/:tabName(${ export const MANAGEMENT_ROUTING_RESPONSE_ACTIONS_HISTORY_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.responseActionsHistory})`; export const MANAGEMENT_ROUTING_SCRIPT_LIBRARY_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.scriptLibrary})`; +/** Routes for the tabbed Artifacts page; used to hide the timeline on every artifact sub-tab. */ +export const ARTIFACT_MANAGEMENT_TAB_ROUTING_PATHS: readonly string[] = [ + MANAGEMENT_ROUTING_ENDPOINT_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, + MANAGEMENT_ROUTING_TRUSTED_DEVICES_PATH, + MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_BLOCKLIST_PATH, +]; + // --[ STORE ]--------------------------------------------------------------------------- /** The SIEM global store namespace where the management state will be mounted */ export const MANAGEMENT_STORE_GLOBAL_NAMESPACE: ManagementStoreGlobalNamespace = 'management'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts index d2db8b0aedb38..fafed859f0ef6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts @@ -33,6 +33,15 @@ export const EVENT_FILTERS_TAB = i18n.translate('xpack.securitySolution.eventFil defaultMessage: 'Event filters', }); +export const HOST_ISOLATION_EXCEPTIONS_TAB = i18n.translate( + 'xpack.securitySolution.artifacts.tabs.hostIsolationExceptions', + { defaultMessage: 'Host isolation exceptions' } +); + +export const BLOCKLIST_TAB = i18n.translate('xpack.securitySolution.artifacts.tabs.blocklist', { + defaultMessage: 'Blocklist', +}); + export const OS_TITLES: Readonly<{ [K in OperatingSystem]: string }> = { [OperatingSystem.WINDOWS]: i18n.translate('xpack.securitySolution.administration.os.windows', { defaultMessage: 'Windows', diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index ec3825c8ead4a..80cf6472dd1fa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -6,12 +6,20 @@ */ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; - +import { css } from '@emotion/react'; + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTextColor, +} from '@elastic/eui'; import type { BulkErrorSchema, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { EuiButton, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout'; import { useLocation } from 'react-router-dom'; import { useIsMounted } from '@kbn/securitysolution-hook-utils'; @@ -321,10 +329,10 @@ export const ArtifactListPage = memo( ) : undefined; return ( - <> + {subtitleText} {detailedPageInfoElement} - + ); }, [labels.pageAboutInfo, secondaryPageInfo]); @@ -368,33 +376,8 @@ export const ArtifactListPage = memo( return ( - {allowCardCreateAction && ( - - {labels.pageAddButtonTitle} - - )} - - {actionsToDisplay.length > 0 && ( - - )} - - } data-test-subj={getTestId('container')} > ( )} {!doesDataExist ? ( - +
* { + justify-content: flex-start; + padding-top: calc((100vh - 140px) / 6); + box-sizing: border-box; + } + `} + > + +
) : ( <> + {backButtonHeaderComponent} + {description} + {callout} - - + + + + + + + {allowCardCreateAction && ( + + {labels.pageAddButtonTitle} + + )} + + {actionsToDisplay.length > 0 && ( + + )} + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts index 294287b41262b..43cf342f59ba8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts @@ -56,7 +56,7 @@ describe('When showing the Empty State in ArtifactListPage', () => { }); }); - it('should hide page headers', async () => { + it('should not show page header (headerless layout)', async () => { render(); expect(renderResult.queryByTestId('header-page-title')).toBe(null); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/privileged_route/privileged_route.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/privileged_route/privileged_route.tsx index 3c43ecdfdc538..b362cae1c74a4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/privileged_route/privileged_route.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/privileged_route/privileged_route.tsx @@ -14,22 +14,26 @@ export interface PrivilegedRouteProps { path: string; component: ComponentType<{}>; hasPrivilege: boolean; + /** When true, the path must match the URL exactly (no extra trailing segments). */ + exact?: boolean; } -export const PrivilegedRoute = memo(({ component, hasPrivilege, path }: PrivilegedRouteProps) => { - const docLinkSelector = useCallback((docLinks: DocLinks) => { - return docLinks.securitySolution.privileges; - }, []); +export const PrivilegedRoute = memo( + ({ component, hasPrivilege, path, exact }: PrivilegedRouteProps) => { + const docLinkSelector = useCallback((docLinks: DocLinks) => { + return docLinks.securitySolution.privileges; + }, []); - const componentToRender = useMemo(() => { - if (!hasPrivilege) { - // eslint-disable-next-line react/display-name - return () => ; - } + const componentToRender = useMemo(() => { + if (!hasPrivilege) { + // eslint-disable-next-line react/display-name + return () => ; + } - return component; - }, [component, docLinkSelector, hasPrivilege]); + return component; + }, [component, docLinkSelector, hasPrivilege]); - return ; -}); + return ; + } +); PrivilegedRoute.displayName = 'PrivilegedRoute'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts index 4fe8240442567..49eb635ba563a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts @@ -49,36 +49,36 @@ describe( }); }; - it('should display Endpoint Exceptions in Administration page', () => { + it('should display Artifacts in Administration page', () => { loginWithReadAccess(); cy.visit(APP_MANAGE_PATH); - cy.getByTestSubj('pageContainer').contains('Endpoint exceptions'); + cy.getByTestSubj('pageContainer').contains('Artifacts'); }); it('should be able to navigate to Endpoint Exceptions from Administration page', () => { loginWithReadAccess(); cy.visit(APP_MANAGE_PATH); - cy.getByTestSubj('pageContainer').contains('Endpoint exceptions').click(); + cy.getByTestSubj('pageContainer').contains('Artifacts').click(); cy.getByTestSubj('endpointExceptionsListPage-container').should('exist'); }); - it('should display Endpoint Exceptions in Manage side panel', () => { + it('should display Artifacts in Manage side panel', () => { loginWithReadAccess(); cy.visit(APP_PATH); - essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); - cy.get(essSecurityHeaders.ENDPOINT_EXCEPTIONS).should('exist'); + essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ARTIFACTS); + cy.get(essSecurityHeaders.ARTIFACTS).should('exist'); }); it('should be able to navigate to Endpoint Exceptions from Manage side panel', () => { loginWithReadAccess(); cy.visit(APP_PATH); - essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); - cy.get(essSecurityHeaders.ENDPOINT_EXCEPTIONS).click(); + essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ARTIFACTS); + cy.get(essSecurityHeaders.ARTIFACTS).click(); cy.getByTestSubj('endpointExceptionsListPage-container').should('exist'); }); @@ -87,17 +87,15 @@ describe( }); describe('Serverless', { tags: ['@serverless', '@skipInServerlessMKI'] }, () => { - it('should display Endpoint Exceptions in Assets side panel ', () => { + it('should display Artifacts in Assets side panel ', () => { // testing with t3_analyst with WRITE access, as we don't support custom roles on serverless yet login(ROLE.t3_analyst); cy.visit(APP_PATH); serverlessSecurityHeaders.showMoreItems(); - serverlessSecurityHeaders.openNavigationPanelFor( - serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS - ); - cy.get(serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS).should('exist'); + serverlessSecurityHeaders.openNavigationPanelFor(serverlessSecurityHeaders.ARTIFACTS); + cy.get(serverlessSecurityHeaders.ARTIFACTS).should('exist'); }); // todo: add 'should NOT' test case when custom roles are available on serverless diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.no_ff.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.no_ff.cy.ts index 96020764a823d..9eed1c1c481b5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.no_ff.cy.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.no_ff.cy.ts @@ -33,9 +33,9 @@ describe('Endpoint exceptions - preserving behaviour without `endpointExceptions loginWithReadAccess(); cy.visit(APP_PATH); - essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); + essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ARTIFACTS); cy.getByTestSubj('solutionSideNavPanel') - .find(essSecurityHeaders.ENDPOINT_EXCEPTIONS) + .find('[data-test-subj="solutionSideNavPanelLink-endpoint_exceptions"]') .should('not.exist'); }); @@ -54,10 +54,8 @@ describe('Endpoint exceptions - preserving behaviour without `endpointExceptions cy.visit(APP_PATH); serverlessSecurityHeaders.showMoreItems(); - serverlessSecurityHeaders.openNavigationPanelFor( - serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS - ); - cy.get(serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS).should('not.exist'); + serverlessSecurityHeaders.openNavigationPanelFor(serverlessSecurityHeaders.ARTIFACTS); + cy.get('[data-test-subj~="nav-item-id-endpoint_exceptions"]').should('not.exist'); }); it('should display Not Found page when opening url directly', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_rbac_test_suite.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_rbac_test_suite.ts index 82130e156ddbe..42958d68b4361 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_rbac_test_suite.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_rbac_test_suite.ts @@ -20,14 +20,15 @@ interface PageEntry { interface NavigationSelectors { ENDPOINTS: string; POLICIES: string; - TRUSTED_APPS: string; - TRUSTED_DEVICES: string; - EVENT_FILTERS: string; - BLOCKLIST: string; - HOST_ISOLATION_EXCEPTIONS: string; + ARTIFACTS: string; RESPONSE_ACTIONS_HISTORY: string; } +/** + * Artifact types share one nav link and one Manage landing card ("Artifacts"). + * Each privilege prefix is tested separately; visibility expectations are the same + * (only the Artifacts link/card vs other management areas). + */ export const getNavigationPages = (selectors: NavigationSelectors): PageEntry[] => [ { name: 'Endpoints', @@ -40,30 +41,35 @@ export const getNavigationPages = (selectors: NavigationSelectors): PageEntry[] selector: selectors.POLICIES, }, { - name: 'Trusted applications', + name: 'Artifacts', privilegePrefix: 'trusted_applications_', - selector: selectors.TRUSTED_APPS, + selector: selectors.ARTIFACTS, }, { - name: 'Trusted devices', + name: 'Artifacts', privilegePrefix: 'trusted_devices_', - selector: selectors.TRUSTED_DEVICES, + selector: selectors.ARTIFACTS, siemVersions: ['siemV3', 'siemV4', 'siemV5'], }, { - name: 'Event filters', + name: 'Artifacts', privilegePrefix: 'event_filters_', - selector: selectors.EVENT_FILTERS, + selector: selectors.ARTIFACTS, }, { - name: 'Blocklist', + name: 'Artifacts', privilegePrefix: 'blocklist_', - selector: selectors.BLOCKLIST, + selector: selectors.ARTIFACTS, }, { - name: 'Host isolation exceptions', + name: 'Artifacts', privilegePrefix: 'host_isolation_exceptions_', - selector: selectors.HOST_ISOLATION_EXCEPTIONS, + selector: selectors.ARTIFACTS, + }, + { + name: 'Artifacts', + privilegePrefix: 'endpoint_exceptions_', + selector: selectors.ARTIFACTS, }, { name: 'Response actions history', @@ -72,12 +78,21 @@ export const getNavigationPages = (selectors: NavigationSelectors): PageEntry[] }, ]; +const describeTitleForPage = (access: string, page: PageEntry): string => { + if (page.name === 'Artifacts') { + return `${access.toUpperCase()} access only to Artifacts (via ${page.privilegePrefix})`; + } + return `${access.toUpperCase()} access only to ${page.name}`; +}; + export const createNavigationEssSuite = (siemVersion: SiemVersion) => { const allPages = getNavigationPages(EssHeaders); const pages = allPages.filter( (page) => !page.siemVersions || page.siemVersions.includes(siemVersion) ); const MenuButtonSelector = EssHeaders.SETTINGS_PANEL_BTN; + const uniqueNavSelectors = [...new Set(pages.map((p) => p.selector))]; + const uniqueLandingNames = [...new Set(pages.map((p) => p.name))]; describe(siemVersion, () => { describe('NONE access', () => { @@ -89,23 +104,23 @@ export const createNavigationEssSuite = (siemVersion: SiemVersion) => { loadPage('/app/security'); cy.get(MenuButtonSelector).click(); - for (const page of pages) { - cy.get(page.selector).should('not.exist'); + for (const selector of uniqueNavSelectors) { + cy.get(selector).should('not.exist'); } }); it(`none of the cards should be visible on Management page`, () => { loadPage('/app/security/manage'); - for (const page of pages) { - cy.getByTestSubj('LandingItem').should('not.contain.text', page.name); + for (const name of uniqueLandingNames) { + cy.getByTestSubj('LandingItem').should('not.contain.text', name); } }); }); for (const access of ['read', 'all']) { for (const page of pages) { - describe(`${access.toUpperCase()} access only to ${page.name}`, () => { + describe(describeTitleForPage(access, page), () => { beforeEach(() => { login.withCustomKibanaPrivileges({ [siemVersion]: ['read', `${page.privilegePrefix}${access}`], @@ -118,7 +133,7 @@ export const createNavigationEssSuite = (siemVersion: SiemVersion) => { cy.get(page.selector); pages - .filter((iterator) => iterator.name !== page.name) + .filter((iterator) => iterator.selector !== page.selector) .forEach((otherPage) => cy.get(otherPage.selector).should('not.exist')); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_serverless.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_serverless.cy.ts index 25ff3a2efe480..d74ff34845705 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_serverless.cy.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/rbac/navigation_serverless.cy.ts @@ -17,15 +17,15 @@ describe( const Selectors = ServerlessHeaders; const MenuButtonSelector = ServerlessHeaders.ASSETS_PANEL_BTN; const allPages = getNavigationPages(Selectors); + const uniqueNavSelectors = [...new Set(allPages.map((p) => p.selector))]; it('without access to any of the subpages, none of those should be displayed', () => { login(ROLE.detections_admin); loadPage('/app/security'); - cy.get(ServerlessHeaders.MORE_MENU_BTN).should('not.exist'); cy.get(MenuButtonSelector).should('not.exist'); - for (const page of allPages) { - cy.get(page.selector).should('not.exist'); + for (const selector of uniqueNavSelectors) { + cy.get(selector).should('not.exist'); } }); @@ -34,12 +34,9 @@ describe( loadPage('/app/security'); ServerlessHeaders.showMoreItems(); cy.get(MenuButtonSelector).click(); - cy.get(allPages[0].selector).click(); - for (const page of allPages) { - if (page.selector !== Selectors.TRUSTED_DEVICES) { - cy.get(page.selector); - } + for (const selector of uniqueNavSelectors) { + cy.get(selector); } }); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/artifacts.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/artifacts.ts index 994b1d94056ca..433f1cc82c2ee 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/artifacts.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/artifacts.ts @@ -32,7 +32,7 @@ const createSubjectSelector = (selectorSuffix: string, pageId?: EndpointArtifact }; export const visitEndpointArtifactPage = (page: EndpointArtifactPageId): Cypress.Chainable => { - return cy.visit(pagesById[page]); + return cy.visit(pagesById[page].url); }; export const getArtifactListEmptyStateAddButton = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts index d68eed69ad11f..b5a1429d19f7a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts @@ -144,9 +144,6 @@ export const getArtifactMockedDataTests = (testData: ArtifactsFixtureType) => () for (const checkResult of testData.create.checkResults) { cy.getByTestSubj(checkResult.selector).should('have.text', checkResult.value); } - - // Title is shown after adding an item - cy.getByTestSubj('header-page-title').contains(testData.title); }); }); @@ -163,7 +160,7 @@ export const getArtifactMockedDataTests = (testData: ArtifactsFixtureType) => () () => { loginWithReadAccess(); loadPage(`/app/security/administration/${testData.urlPath}`); - cy.getByTestSubj('header-page-title').contains(testData.title); + cy.getByTestSubj(`${testData.pagePrefix}-container`).should('be.visible'); cy.getByTestSubj(`${testData.pagePrefix}-card-header-actions-button`).should( 'not.exist' ); @@ -179,7 +176,7 @@ export const getArtifactMockedDataTests = (testData: ArtifactsFixtureType) => () () => { loginWithReadAccess(); loadPage(`/app/security/administration/${testData.urlPath}`); - cy.getByTestSubj('header-page-title').contains(testData.title); + cy.getByTestSubj(`${testData.pagePrefix}-container`).should('be.visible'); cy.getByTestSubj(`${testData.pagePrefix}-pageAddButton`).should('not.exist'); } ); @@ -199,9 +196,6 @@ export const getArtifactMockedDataTests = (testData: ArtifactsFixtureType) => () for (const checkResult of testData.update.checkResults) { cy.getByTestSubj(checkResult.selector).should('have.text', checkResult.value); } - - // Title still shown after editing an item - cy.getByTestSubj('header-page-title').contains(testData.title); }); it(`write - should be able to delete the existing ${testData.title} entry`, () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/links.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/links.test.ts index 468b69460ea47..559327ed6a88b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/links.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/links.test.ts @@ -12,7 +12,12 @@ import { SecurityPageName } from '../app/types'; import { calculateEndpointAuthz } from '../../common/endpoint/service/authz'; import type { StartPlugins } from '../types'; -import { getManagementFilteredLinks, links } from './links'; +import { getFirstAllowedArtifactPath, getManagementFilteredLinks, links } from './links'; +import { + getEndpointExceptionsListPath, + getEventFiltersListPath, + getTrustedAppsListPath, +} from './common/routing'; import { allowedExperimentalValues } from '../../common/experimental_features'; import { ExperimentalFeaturesService } from '../common/experimental_features_service'; import { getEndpointAuthzInitialStateMock } from '../../common/endpoint/service/authz/mocks'; @@ -20,7 +25,6 @@ import { licenseService as _licenseService } from '../common/hooks/use_license'; import type { LicenseService } from '../../common/license'; import { createLicenseServiceMock } from '../../common/license/mocks'; import { createFleetAuthzMock } from '@kbn/fleet-plugin/common/mocks'; -import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; jest.mock('../common/hooks/use_license'); @@ -76,26 +80,135 @@ describe('links', () => { Object.assign(licenseServiceMock, createLicenseServiceMock()); }); + describe('Endpoints category structure', () => { + it('should order Endpoints section links as Endpoints, Policies, Artifacts, Response actions history, Script library', () => { + const endpointsCategory = links.categories?.find((category) => + category.linkIds?.includes(SecurityPageName.endpoints) + ); + expect(endpointsCategory).toBeDefined(); + expect(endpointsCategory?.linkIds).toEqual([ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.artifacts, + SecurityPageName.responseActionsHistory, + SecurityPageName.scriptLibrary, + ]); + }); + }); + + describe('getFirstAllowedArtifactPath', () => { + const experimentalDefaults = { + ...allowedExperimentalValues, + endpointExceptionsMovedUnderManagement: true, + trustedDevices: true, + }; + + it('should return endpoint exceptions path when FF is on and user can read endpoint exceptions', () => { + expect( + getFirstAllowedArtifactPath( + { + canReadEndpointExceptions: true, + canReadTrustedApplications: true, + canReadTrustedDevices: true, + canReadEventFilters: true, + showHostIsolationExceptions: true, + canReadBlocklist: true, + }, + experimentalDefaults + ) + ).toBe(getEndpointExceptionsListPath()); + }); + + it('should return trusted apps path when endpoint exceptions not allowed but trusted apps are', () => { + expect( + getFirstAllowedArtifactPath( + { + canReadEndpointExceptions: false, + canReadTrustedApplications: true, + canReadTrustedDevices: false, + canReadEventFilters: false, + showHostIsolationExceptions: false, + canReadBlocklist: false, + }, + experimentalDefaults + ) + ).toBe(getTrustedAppsListPath()); + }); + + it('should return event filters path when only event filters are readable', () => { + expect( + getFirstAllowedArtifactPath( + { + canReadEndpointExceptions: false, + canReadTrustedApplications: false, + canReadTrustedDevices: false, + canReadEventFilters: true, + showHostIsolationExceptions: false, + canReadBlocklist: false, + }, + experimentalDefaults + ) + ).toBe(getEventFiltersListPath()); + }); + + it('should return trusted apps path when endpoint exceptions FF is off even if user can read them', () => { + expect( + getFirstAllowedArtifactPath( + { + canReadEndpointExceptions: true, + canReadTrustedApplications: true, + canReadTrustedDevices: false, + canReadEventFilters: false, + showHostIsolationExceptions: false, + canReadBlocklist: false, + }, + { + ...allowedExperimentalValues, + endpointExceptionsMovedUnderManagement: false, + trustedDevices: true, + } + ) + ).toBe(getTrustedAppsListPath()); + }); + + it('should fall back to trusted apps path when no artifact read privilege matches', () => { + expect( + getFirstAllowedArtifactPath( + { + canReadEndpointExceptions: false, + canReadTrustedApplications: false, + canReadTrustedDevices: false, + canReadEventFilters: false, + showHostIsolationExceptions: false, + canReadBlocklist: false, + }, + experimentalDefaults + ) + ).toBe(getTrustedAppsListPath()); + }); + }); + it('should return all links for user with all sub-feature privileges', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock()); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - expect(filteredLinks).toEqual(links); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); + expect(filteredLinks.links?.map((l) => l.id)).toEqual(links.links?.map((l) => l.id)); + const artifactsLink = filteredLinks.links?.find((l) => l.id === SecurityPageName.artifacts); + expect(artifactsLink?.path).toBeDefined(); }); it('should not return any endpoint management link for user with all sub-feature privileges when no user authz', async () => { - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(true)); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(true), { + ...allowedExperimentalValues, + }); expect(filteredLinks).toEqual( getLinksWithout( - SecurityPageName.blocklist, + SecurityPageName.artifacts, SecurityPageName.endpoints, - SecurityPageName.endpointExceptions, - SecurityPageName.eventFilters, - SecurityPageName.hostIsolationExceptions, SecurityPageName.policies, SecurityPageName.responseActionsHistory, - SecurityPageName.trustedApps, - SecurityPageName.trustedDevices, SecurityPageName.cloudDefendPolicies, SecurityPageName.scriptLibrary ) @@ -112,138 +225,81 @@ describe('links', () => { ); fakeHttpServices.get.mockResolvedValue({ total: 0 }); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.responseActionsHistory)); - }); - }); - - describe('Host Isolation Exception', () => { - const apiVersion = '2023-10-31'; - it('should return HIE if user has access permission (licensed)', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ canAccessHostIsolationExceptions: true }) - ); - - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - - expect(filteredLinks).toEqual(links); - expect(fakeHttpServices.get).not.toHaveBeenCalled(); - }); - - it('should NOT return HIE if the user has no HIE permission', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ - canAccessHostIsolationExceptions: false, - canReadHostIsolationExceptions: false, - }) - ); - - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); - expect(fakeHttpServices.get).not.toHaveBeenCalled(); - }); - - it('should NOT return HIE if user has read permission (no license) and NO HIE entries exist', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ - canAccessHostIsolationExceptions: false, - canReadHostIsolationExceptions: true, - }) - ); - - fakeHttpServices.get.mockResolvedValue({ total: 0 }); - - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); - expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { - version: apiVersion, - query: expect.objectContaining({ - list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id], - }), + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, }); - }); - - it('should return HIE if user has read permission (no license) but HIE entries exist', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ - canAccessHostIsolationExceptions: false, - canReadHostIsolationExceptions: true, - }) + expect(filteredLinks.links?.map((l) => l.id)).toEqual( + getLinksWithout(SecurityPageName.responseActionsHistory).links?.map((l) => l.id) ); - - fakeHttpServices.get.mockResolvedValue({ total: 100 }); - - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - - expect(filteredLinks).toEqual(links); - expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { - version: apiVersion, - query: expect.objectContaining({ - list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id], - }), - }); }); }); - describe('RBAC checks', () => { - it('should return all links for user with all sub-feature privileges', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock()); - - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - - expect(filteredLinks).toEqual(links); - }); - - it('should hide Trusted Applications for user without privilege', async () => { + describe('Artifacts', () => { + it('should hide Artifacts when user has no artifact privilege', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue( getEndpointAuthzInitialStateMock({ + canReadEndpointExceptions: false, canReadTrustedApplications: false, + canReadTrustedDevices: false, + canReadEventFilters: false, + canReadHostIsolationExceptions: false, + canAccessHostIsolationExceptions: false, + canReadBlocklist: false, }) ); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.trustedApps)); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.artifacts)); }); - it('should hide Endpoint Exceptions for user without privilege', async () => { + it('should show Artifacts when user has at least one artifact privilege', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue( getEndpointAuthzInitialStateMock({ canReadEndpointExceptions: false, - }) - ); - - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.endpointExceptions)); - }); - - it('should hide Event Filters for user without privilege', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ + canReadTrustedApplications: false, + canReadTrustedDevices: false, canReadEventFilters: false, + canReadHostIsolationExceptions: false, + canAccessHostIsolationExceptions: false, + canReadBlocklist: true, }) ); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.eventFilters)); + const artifactsLink = filteredLinks.links?.find((l) => l.id === SecurityPageName.artifacts); + expect(artifactsLink).toBeDefined(); + expect(artifactsLink?.path).toBeDefined(); }); - it('should hide Blocklist for user without privilege', async () => { + it('should set Artifacts link path to first allowed artifact tab (event filters only)', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue( getEndpointAuthzInitialStateMock({ + canReadEndpointExceptions: false, + canReadTrustedApplications: false, + canReadTrustedDevices: false, + canReadEventFilters: true, + canReadHostIsolationExceptions: false, + canAccessHostIsolationExceptions: false, canReadBlocklist: false, }) ); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.blocklist)); + const artifactsLink = filteredLinks.links?.find((l) => l.id === SecurityPageName.artifacts); + expect(artifactsLink?.path).toBe(getEventFiltersListPath()); }); + }); + describe('RBAC checks', () => { it('should NOT return policies if `canReadPolicyManagement` is `false`', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue( getEndpointAuthzInitialStateMock({ @@ -251,10 +307,14 @@ describe('links', () => { }) ); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); - expect(filteredLinks).toEqual( - getLinksWithout(SecurityPageName.policies, SecurityPageName.cloudDefendPolicies) + expect(filteredLinks.links?.map((l) => l.id)).toEqual( + getLinksWithout(SecurityPageName.policies, SecurityPageName.cloudDefendPolicies).links?.map( + (l) => l.id + ) ); }); @@ -265,7 +325,9 @@ describe('links', () => { }) ); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.scriptLibrary)); }); @@ -278,8 +340,12 @@ describe('links', () => { canReadEndpointList: false, }) ); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins()); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.endpoints)); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins(), { + ...allowedExperimentalValues, + }); + expect(filteredLinks.links?.map((l) => l.id)).toEqual( + getLinksWithout(SecurityPageName.endpoints).links?.map((l) => l.id) + ); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/links.ts b/x-pack/solutions/security/plugins/security_solution/public/management/links.ts index 9a36cd3874f74..389a2f6290ae2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/links.ts @@ -15,12 +15,8 @@ import { getEndpointAuthzInitialState, } from '../../common/endpoint/service/authz'; import { - BLOCKLIST_PATH, - ENDPOINT_EXCEPTIONS_PATH, ENDPOINTS_PATH, ENTITY_ANALYTICS_MANAGEMENT_PATH, - EVENT_FILTERS_PATH, - HOST_ISOLATION_EXCEPTIONS_PATH, MANAGE_PATH, POLICIES_PATH, RESPONSE_ACTIONS_HISTORY_PATH, @@ -28,38 +24,36 @@ import { SECURITY_FEATURE_ID, SecurityPageName, TRUSTED_APPS_PATH, - TRUSTED_DEVICES_PATH, } from '../../common/constants'; import { - BLOCKLIST, - ENDPOINT_EXCEPTIONS, + ARTIFACTS, ENDPOINTS, ENTITY_ANALYTICS, - EVENT_FILTERS, - HOST_ISOLATION_EXCEPTIONS, MANAGE, POLICIES, RESPONSE_ACTIONS_HISTORY, SCRIPT_LIBRARY, - TRUSTED_APPLICATIONS, - TRUSTED_DEVICES, } from '../app/translations'; import { licenseService } from '../common/hooks/use_license'; +import type { ExperimentalFeatures } from '../../common/experimental_features'; import type { LinkItem } from '../common/links/types'; import type { StartPlugins } from '../types'; import { links as notesLink } from '../notes/links'; +import { + getBlocklistsListPath, + getEndpointExceptionsListPath, + getEventFiltersListPath, + getHostIsolationExceptionsListPath, + getTrustedAppsListPath, + getTrustedDevicesListPath, +} from './common/routing'; import { IconResponseActionHistory } from '../common/icons/response_action_history'; -import { IconBlocklist } from '../common/icons/blocklist'; import { IconEndpoints } from '../common/icons/endpoints'; import { IconPolicies } from '../common/icons/policies'; -import { IconEventFilters } from '../common/icons/event_filters'; -import { IconHostIsolationExceptions } from '../common/icons/host_isolation_exceptions'; -import { IconTrustedApplications } from '../common/icons/trusted_applications'; +import { IconArtifacts } from '../common/icons/artifacts'; import { IconEntityAnalytics } from '../common/icons/entity_analytics'; -import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client'; -import { IconTrustedDevices } from '../common/icons/trusted_devices'; -import { IconEndpointExceptions } from '../common/icons/endpoint_exceptions'; import { IconScriptLibrary } from '../common/icons/script_library'; +import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client'; import { KibanaServices } from '../common/lib/kibana'; const categories = [ @@ -76,12 +70,7 @@ const categories = [ linkIds: [ SecurityPageName.endpoints, SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.trustedDevices, - SecurityPageName.eventFilters, - SecurityPageName.hostIsolationExceptions, - SecurityPageName.blocklist, - SecurityPageName.endpointExceptions, + SecurityPageName.artifacts, SecurityPageName.responseActionsHistory, SecurityPageName.scriptLibrary, ], @@ -139,80 +128,16 @@ export const links: LinkItem = { hideTimeline: true, }, { - id: SecurityPageName.trustedApps, - title: TRUSTED_APPLICATIONS, - description: i18n.translate( - 'xpack.securitySolution.appLinks.trustedApplicationsDescription', - { - defaultMessage: - 'Improve performance or alleviate conflicts with other applications running on your hosts.', - } - ), - landingIcon: IconTrustedApplications, - path: TRUSTED_APPS_PATH, - skipUrlState: true, - hideTimeline: true, - }, - { - id: SecurityPageName.trustedDevices, - title: TRUSTED_DEVICES, - description: i18n.translate('xpack.securitySolution.appLinks.trustedDevicesDescription', { + id: SecurityPageName.artifacts, + title: ARTIFACTS, + description: i18n.translate('xpack.securitySolution.appLinks.artifactsDescription', { defaultMessage: - 'Specify which external devices can connect to your endpoints even when Device Control is enabled.', - }), - landingIcon: IconTrustedDevices, - path: TRUSTED_DEVICES_PATH, - skipUrlState: true, - hideTimeline: true, - experimentalKey: 'trustedDevices', - capabilities: [`${SECURITY_FEATURE_ID}.readTrustedDevices`], - licenseType: 'enterprise', - }, - { - id: SecurityPageName.eventFilters, - title: EVENT_FILTERS, - description: i18n.translate('xpack.securitySolution.appLinks.eventFiltersDescription', { - defaultMessage: 'Exclude high volume or unwanted events being written into Elasticsearch.', - }), - landingIcon: IconEventFilters, - path: EVENT_FILTERS_PATH, - skipUrlState: true, - hideTimeline: true, - }, - { - id: SecurityPageName.hostIsolationExceptions, - title: HOST_ISOLATION_EXCEPTIONS, - description: i18n.translate('xpack.securitySolution.appLinks.hostIsolationDescription', { - defaultMessage: 'Allow isolated hosts to communicate with specific IPs.', - }), - landingIcon: IconHostIsolationExceptions, - path: HOST_ISOLATION_EXCEPTIONS_PATH, - skipUrlState: true, - hideTimeline: true, - }, - { - id: SecurityPageName.blocklist, - title: BLOCKLIST, - description: i18n.translate('xpack.securitySolution.appLinks.blocklistDescription', { - defaultMessage: 'Exclude unwanted applications from running on your hosts.', - }), - landingIcon: IconBlocklist, - path: BLOCKLIST_PATH, - skipUrlState: true, - hideTimeline: true, - }, - { - id: SecurityPageName.endpointExceptions, - title: ENDPOINT_EXCEPTIONS, - description: i18n.translate('xpack.securitySolution.appLinks.endpointExceptionsDescription', { - defaultMessage: 'Add exceptions to your hosts.', + 'Manage exceptions, trusted applications, and other settings that control how endpoints are protected and respond to activity.', }), - landingIcon: IconEndpointExceptions, - path: ENDPOINT_EXCEPTIONS_PATH, + landingIcon: IconArtifacts, + path: TRUSTED_APPS_PATH, skipUrlState: true, hideTimeline: true, - - experimentalKey: 'endpointExceptionsMovedUnderManagement', }, { id: SecurityPageName.entityAnalyticsManagement, @@ -265,10 +190,64 @@ const excludeLinks = (linkIds: SecurityPageName[]) => ({ links: links.links?.filter((link) => !linkIds.includes(link.id)), }); +/** Artifact read flags used to compute first allowed artifact path. */ +export interface ArtifactAuthz { + canReadEndpointExceptions: boolean; + canReadTrustedApplications: boolean; + canReadTrustedDevices: boolean; + canReadEventFilters: boolean; + showHostIsolationExceptions: boolean; + canReadBlocklist: boolean; +} + +/** + * Returns the path for the first artifact tab the user is allowed to access. + * Order matches the Artifacts page tab order so the link always points at an allowed route. + */ +export const getFirstAllowedArtifactPath = ( + artifactAuthz: ArtifactAuthz, + experimentalFeatures: ExperimentalFeatures +): string => { + const { endpointExceptionsMovedUnderManagement, trustedDevices: trustedDevicesEnabled } = + experimentalFeatures; + const { + canReadEndpointExceptions, + canReadTrustedApplications, + canReadTrustedDevices, + canReadEventFilters, + showHostIsolationExceptions, + canReadBlocklist, + } = artifactAuthz; + + if (endpointExceptionsMovedUnderManagement && canReadEndpointExceptions) { + return getEndpointExceptionsListPath(); + } + if (canReadTrustedApplications) { + return getTrustedAppsListPath(); + } + if (trustedDevicesEnabled && canReadTrustedDevices) { + return getTrustedDevicesListPath(); + } + if (canReadEventFilters) { + return getEventFiltersListPath(); + } + if (showHostIsolationExceptions) { + return getHostIsolationExceptionsListPath(); + } + if (canReadBlocklist) { + return getBlocklistsListPath(); + } + return getTrustedAppsListPath(); +}; + export const getManagementFilteredLinks = async ( core: CoreStart, - plugins: StartPlugins + plugins: StartPlugins, + experimentalFeatures: ExperimentalFeatures ): Promise => { + const { endpointExceptionsMovedUnderManagement, trustedDevices: trustedDevicesEnabled } = + experimentalFeatures; + const fleetAuthz = plugins.fleet?.authz; const currentUser = await plugins.security.authc.getCurrentUser(); const isServerless = KibanaServices.getBuildFlavor() === 'serverless'; @@ -308,37 +287,50 @@ export const getManagementFilteredLinks = async ( linksToExclude.push(SecurityPageName.cloudDefendPolicies); } - if (!canReadEndpointExceptions) { - linksToExclude.push(SecurityPageName.endpointExceptions); + const canReadAnyArtifact = + (endpointExceptionsMovedUnderManagement && canReadEndpointExceptions) || + canReadTrustedApplications || + (trustedDevicesEnabled && canReadTrustedDevices) || + canReadEventFilters || + showHostIsolationExceptions || + canReadBlocklist; + if (!canReadAnyArtifact) { + linksToExclude.push(SecurityPageName.artifacts); } if (!canReadActionsLogManagement) { linksToExclude.push(SecurityPageName.responseActionsHistory); } - if (!showHostIsolationExceptions) { - linksToExclude.push(SecurityPageName.hostIsolationExceptions); - } - - if (!canReadTrustedApplications) { - linksToExclude.push(SecurityPageName.trustedApps); - } - - if (!canReadTrustedDevices) { - linksToExclude.push(SecurityPageName.trustedDevices); + if (!canReadScriptsLibrary) { + linksToExclude.push(SecurityPageName.scriptLibrary); } - if (!canReadEventFilters) { - linksToExclude.push(SecurityPageName.eventFilters); - } + const filtered = excludeLinks(linksToExclude); - if (!canReadBlocklist) { - linksToExclude.push(SecurityPageName.blocklist); - } + const artifactsPath = canReadAnyArtifact + ? getFirstAllowedArtifactPath( + { + canReadEndpointExceptions, + canReadTrustedApplications, + canReadTrustedDevices, + canReadEventFilters, + showHostIsolationExceptions, + canReadBlocklist, + }, + experimentalFeatures + ) + : undefined; - if (!canReadScriptsLibrary) { - linksToExclude.push(SecurityPageName.scriptLibrary); - } + const linksWithArtifactsPath = + filtered.links?.map((link) => + link.id === SecurityPageName.artifacts && artifactsPath != null + ? { ...link, path: artifactsPath } + : link + ) ?? []; - return excludeLinks(linksToExclude); + return { + ...filtered, + links: linksWithArtifactsPath, + }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/artifacts_page.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/artifacts_page.test.tsx new file mode 100644 index 0000000000000..82bc83677a416 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/artifacts_page.test.tsx @@ -0,0 +1,293 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { act, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + BLOCKLIST_PATH, + ENDPOINT_EXCEPTIONS_PATH, + EVENT_FILTERS_PATH, + HOST_ISOLATION_EXCEPTIONS_PATH, + TRUSTED_APPS_PATH, +} from '../../../../common/constants'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import type { EndpointPrivileges } from '../../../../common/endpoint/types'; +import { ArtifactsPage } from './artifacts_page'; +import { + BLOCKLIST_TAB, + ENDPOINT_EXCEPTIONS_TAB, + EVENT_FILTERS_TAB, + HOST_ISOLATION_EXCEPTIONS_TAB, + TRUSTED_APPS_TAB, + TRUSTED_DEVICES_TAB, +} from '../../common/translations'; +import { useHostIsolationExceptionsAccess } from '../../hooks/artifacts/use_host_isolation_exceptions_access'; + +jest.mock('../../../common/components/user_privileges'); +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; + +jest.mock('../../hooks/artifacts/use_host_isolation_exceptions_access'); +const mockUseHostIsolationExceptionsAccess = + useHostIsolationExceptionsAccess as jest.MockedFunction; + +jest.mock('../endpoint_exceptions/view/endpoint_exceptions', () => ({ + EndpointExceptions: () => ( +
{'endpoint-exceptions'}
+ ), +})); + +jest.mock('../trusted_apps/view/trusted_apps_list', () => ({ + TrustedAppsList: () =>
{'trusted-apps'}
, +})); + +jest.mock('../trusted_devices/view/trusted_devices_list', () => ({ + TrustedDevicesList: () => ( +
{'trusted-devices'}
+ ), +})); + +jest.mock('../event_filters/view/event_filters_list', () => ({ + EventFiltersList: () =>
{'event-filters'}
, +})); + +jest.mock('../host_isolation_exceptions/view/host_isolation_exceptions_list', () => ({ + HostIsolationExceptionsList: () => ( +
{'host-isolation'}
+ ), +})); + +jest.mock('../blocklist/view/blocklist', () => ({ + Blocklist: () =>
{'blocklist'}
, +})); + +const fullArtifactReadPrivileges: Partial = { + canReadEndpointExceptions: true, + canReadTrustedApplications: true, + canReadTrustedDevices: true, + canReadEventFilters: true, + canReadHostIsolationExceptions: true, + canAccessHostIsolationExceptions: true, + canReadBlocklist: true, +}; + +describe('ArtifactsPage', () => { + let mockedContext: AppContextTestRender; + let history: AppContextTestRender['history']; + let renderResult: ReturnType; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + ({ history } = mockedContext); + mockedContext.setExperimentalFlag({ + endpointExceptionsMovedUnderManagement: true, + trustedDevices: true, + }); + mockUseUserPrivileges.mockReturnValue({ + endpointPrivileges: fullArtifactReadPrivileges, + }); + mockUseHostIsolationExceptionsAccess.mockReturnValue({ + hasAccessToHostIsolationExceptions: true, + isHostIsolationExceptionsAccessLoading: false, + }); + }); + + afterEach(() => { + mockUseUserPrivileges.mockReset(); + mockUseHostIsolationExceptionsAccess.mockReset(); + }); + + const renderArtifactsAtPath = (path: string) => { + act(() => { + history.push(path); + }); + return mockedContext.render(); + }; + + it('renders a single Artifacts page with the default tab matching the URL (trusted apps)', async () => { + renderResult = renderArtifactsAtPath(TRUSTED_APPS_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('artifactsPage')).toBeInTheDocument(); + }); + + const tabs = renderResult.getByRole('tablist'); + const selectedTab = within(tabs).getByRole('tab', { selected: true }); + expect(selectedTab).toHaveTextContent(TRUSTED_APPS_TAB); + expect(renderResult.getByTestId('artifacts-stub-trustedApps')).toBeInTheDocument(); + }); + + it('shows all artifact tabs when privileges and feature flags allow', async () => { + renderResult = renderArtifactsAtPath(TRUSTED_APPS_PATH); + + await waitFor(() => { + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + }); + + const tabs = renderResult.getByRole('tablist'); + expect(within(tabs).getByRole('tab', { name: ENDPOINT_EXCEPTIONS_TAB })).toBeInTheDocument(); + expect(within(tabs).getByRole('tab', { name: TRUSTED_APPS_TAB })).toBeInTheDocument(); + expect(within(tabs).getByRole('tab', { name: TRUSTED_DEVICES_TAB })).toBeInTheDocument(); + expect(within(tabs).getByRole('tab', { name: EVENT_FILTERS_TAB })).toBeInTheDocument(); + expect( + within(tabs).getByRole('tab', { name: HOST_ISOLATION_EXCEPTIONS_TAB }) + ).toBeInTheDocument(); + expect(within(tabs).getByRole('tab', { name: BLOCKLIST_TAB })).toBeInTheDocument(); + }); + + it('deep link opens the correct tab and content (event filters)', async () => { + renderResult = renderArtifactsAtPath(EVENT_FILTERS_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-eventFilters')).toBeInTheDocument(); + }); + + const tabs = renderResult.getByRole('tablist'); + expect(within(tabs).getByRole('tab', { selected: true })).toHaveTextContent(EVENT_FILTERS_TAB); + expect(history.location.pathname).toBe(EVENT_FILTERS_PATH); + }); + + it('deep link opens blocklist tab from URL', async () => { + renderResult = renderArtifactsAtPath(BLOCKLIST_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-blocklist')).toBeInTheDocument(); + }); + + expect(history.location.pathname).toBe(BLOCKLIST_PATH); + }); + + it('switches tabs and updates URL and content when a tab is clicked', async () => { + const user = userEvent.setup(); + renderResult = renderArtifactsAtPath(TRUSTED_APPS_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-trustedApps')).toBeInTheDocument(); + }); + + await user.click( + within(renderResult.getByRole('tablist')).getByRole('tab', { name: BLOCKLIST_TAB }) + ); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-blocklist')).toBeInTheDocument(); + }); + expect(history.location.pathname).toBe(BLOCKLIST_PATH); + expect(renderResult.queryByTestId('artifacts-stub-trustedApps')).not.toBeInTheDocument(); + }); + + it('supports browser back and forward between artifact tabs', async () => { + const user = userEvent.setup(); + renderResult = renderArtifactsAtPath(TRUSTED_APPS_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-trustedApps')).toBeInTheDocument(); + }); + + await user.click( + within(renderResult.getByRole('tablist')).getByRole('tab', { name: ENDPOINT_EXCEPTIONS_TAB }) + ); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-endpointExceptions')).toBeInTheDocument(); + }); + expect(history.location.pathname).toBe(ENDPOINT_EXCEPTIONS_PATH); + + act(() => { + history.goBack(); + }); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-trustedApps')).toBeInTheDocument(); + }); + expect(history.location.pathname).toBe(TRUSTED_APPS_PATH); + + act(() => { + history.goForward(); + }); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-endpointExceptions')).toBeInTheDocument(); + }); + expect(history.location.pathname).toBe(ENDPOINT_EXCEPTIONS_PATH); + }); + + it('hides Endpoint exceptions tab when endpointExceptionsMovedUnderManagement is off', async () => { + mockedContext.setExperimentalFlag({ + endpointExceptionsMovedUnderManagement: false, + trustedDevices: true, + }); + renderResult = renderArtifactsAtPath(TRUSTED_APPS_PATH); + + await waitFor(() => { + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + }); + + const tabs = renderResult.getByRole('tablist'); + expect( + within(tabs).queryByRole('tab', { name: ENDPOINT_EXCEPTIONS_TAB }) + ).not.toBeInTheDocument(); + }); + + it('hides Trusted devices tab when trustedDevices feature is off', async () => { + mockedContext.setExperimentalFlag({ + endpointExceptionsMovedUnderManagement: true, + trustedDevices: false, + }); + renderResult = renderArtifactsAtPath(TRUSTED_APPS_PATH); + + await waitFor(() => { + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + }); + + const tabs = renderResult.getByRole('tablist'); + expect(within(tabs).queryByRole('tab', { name: TRUSTED_DEVICES_TAB })).not.toBeInTheDocument(); + }); + + it('defaults to first visible tab when URL does not match an artifact tab', async () => { + renderResult = renderArtifactsAtPath('/administration/unknown_artifact_path'); + + await waitFor(() => { + expect(renderResult.getByTestId('artifacts-stub-endpointExceptions')).toBeInTheDocument(); + }); + + const tabs = renderResult.getByRole('tablist'); + expect(within(tabs).getByRole('tab', { selected: true })).toHaveTextContent( + ENDPOINT_EXCEPTIONS_TAB + ); + }); + + it('shows no privileges page when host isolation exceptions URL is visited without access', async () => { + mockUseHostIsolationExceptionsAccess.mockReturnValue({ + hasAccessToHostIsolationExceptions: false, + isHostIsolationExceptionsAccessLoading: false, + }); + renderResult = renderArtifactsAtPath(HOST_ISOLATION_EXCEPTIONS_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('noPrivilegesPage')).toBeInTheDocument(); + }); + expect( + renderResult.queryByTestId('artifacts-stub-hostIsolationExceptions') + ).not.toBeInTheDocument(); + }); + + it('shows a loading indicator while host isolation exceptions access is resolving', async () => { + mockUseHostIsolationExceptionsAccess.mockReturnValue({ + hasAccessToHostIsolationExceptions: false, + isHostIsolationExceptionsAccessLoading: true, + }); + renderResult = renderArtifactsAtPath(HOST_ISOLATION_EXCEPTIONS_PATH); + + await waitFor(() => { + expect(renderResult.getByTestId('artifactsPage-hieAccessLoading')).toBeInTheDocument(); + }); + expect(renderResult.queryByTestId('noPrivilegesPage')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/artifacts_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/artifacts_page.tsx new file mode 100644 index 0000000000000..3efa9677b4b9e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/artifacts_page.tsx @@ -0,0 +1,271 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { memo, useMemo, useCallback } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { + EuiTabs, + EuiTab, + EuiSpacer, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SecurityPageName } from '@kbn/deeplinks-security'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { AdministrationListPage } from '../../components/administration_list_page'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useHttp } from '../../../common/lib/kibana'; +import { NoPrivilegesPage } from '../../../common/components/no_privileges'; +import { useHostIsolationExceptionsAccess } from '../../hooks/artifacts/use_host_isolation_exceptions_access'; +import { HostIsolationExceptionsApiClient } from '../host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { AdministrationSubTab } from '../../types'; +import { + getEndpointExceptionsListPath, + getTrustedAppsListPath, + getTrustedDevicesListPath, + getEventFiltersListPath, + getHostIsolationExceptionsListPath, + getBlocklistsListPath, +} from '../../common/routing'; +import { EndpointExceptions } from '../endpoint_exceptions/view/endpoint_exceptions'; +import { TrustedAppsList } from '../trusted_apps/view/trusted_apps_list'; +import { TrustedDevicesList } from '../trusted_devices/view/trusted_devices_list'; +import { EventFiltersList } from '../event_filters/view/event_filters_list'; +import { HostIsolationExceptionsList } from '../host_isolation_exceptions/view/host_isolation_exceptions_list'; +import { Blocklist } from '../blocklist/view/blocklist'; +import { + ENDPOINT_EXCEPTIONS_TAB, + TRUSTED_APPS_TAB, + TRUSTED_DEVICES_TAB, + EVENT_FILTERS_TAB, + HOST_ISOLATION_EXCEPTIONS_TAB, + BLOCKLIST_TAB, +} from '../../common/translations'; + +const ARTIFACTS_PAGE_TITLE = i18n.translate('xpack.securitySolution.artifacts.pageTitle', { + defaultMessage: 'Artifacts', +}); + +const ARTIFACTS_PAGE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.artifacts.pageDescription', + { + defaultMessage: + 'Manage exceptions, trusted applications, and other settings that control how endpoints are protected and respond to activity.', + } +); + +const TAB_PAGE_NAMES: Partial> = { + [AdministrationSubTab.endpointExceptions]: SecurityPageName.endpointExceptions, + [AdministrationSubTab.trustedApps]: SecurityPageName.trustedApps, + [AdministrationSubTab.trustedDevices]: SecurityPageName.trustedDevices, + [AdministrationSubTab.eventFilters]: SecurityPageName.eventFilters, + [AdministrationSubTab.hostIsolationExceptions]: SecurityPageName.hostIsolationExceptions, + [AdministrationSubTab.blocklist]: SecurityPageName.blocklist, +}; + +const ARTIFACT_SUB_TABS: AdministrationSubTab[] = [ + AdministrationSubTab.endpointExceptions, + AdministrationSubTab.trustedApps, + AdministrationSubTab.trustedDevices, + AdministrationSubTab.eventFilters, + AdministrationSubTab.hostIsolationExceptions, + AdministrationSubTab.blocklist, +]; + +function getTabLabel(tab: AdministrationSubTab): string { + switch (tab) { + case AdministrationSubTab.endpointExceptions: + return ENDPOINT_EXCEPTIONS_TAB; + case AdministrationSubTab.trustedApps: + return TRUSTED_APPS_TAB; + case AdministrationSubTab.trustedDevices: + return TRUSTED_DEVICES_TAB; + case AdministrationSubTab.eventFilters: + return EVENT_FILTERS_TAB; + case AdministrationSubTab.hostIsolationExceptions: + return HOST_ISOLATION_EXCEPTIONS_TAB; + case AdministrationSubTab.blocklist: + return BLOCKLIST_TAB; + default: + return tab; + } +} + +function getPathForTab( + tab: AdministrationSubTab, + visibleTabs: AdministrationSubTab[] = [] +): string { + switch (tab) { + case AdministrationSubTab.endpointExceptions: + return getEndpointExceptionsListPath(); + case AdministrationSubTab.trustedApps: + return getTrustedAppsListPath(); + case AdministrationSubTab.trustedDevices: + return getTrustedDevicesListPath(); + case AdministrationSubTab.eventFilters: + return getEventFiltersListPath(); + case AdministrationSubTab.hostIsolationExceptions: + return getHostIsolationExceptionsListPath(); + case AdministrationSubTab.blocklist: + return getBlocklistsListPath(); + default: + // visibleTabs[0] covers all reachable cases; the getTrustedAppsListPath fallback + // is unreachable because canReadAnyArtifact would be false and the link excluded + return visibleTabs.length > 0 ? getPathForTab(visibleTabs[0]) : getTrustedAppsListPath(); + } +} + +function getActiveTabFromPathname( + pathname: string, + visibleTabs: AdministrationSubTab[] +): AdministrationSubTab { + for (const tab of ARTIFACT_SUB_TABS) { + if (pathname.includes(`/${tab}`)) { + return tab; + } + } + return visibleTabs[0] ?? AdministrationSubTab.trustedApps; +} + +export const ArtifactsPage = memo(() => { + const location = useLocation(); + const history = useHistory(); + const http = useHttp(); + const endpointExceptionsMovedUnderManagement = useIsExperimentalFeatureEnabled( + 'endpointExceptionsMovedUnderManagement' + ); + const trustedDevicesEnabled = useIsExperimentalFeatureEnabled('trustedDevices'); + const { + canReadBlocklist, + canReadTrustedApplications, + canReadTrustedDevices, + canReadEventFilters, + canReadHostIsolationExceptions, + canAccessHostIsolationExceptions, + canReadEndpointExceptions, + } = useUserPrivileges().endpointPrivileges; + + const getHostIsolationExceptionsApiClientInstance = useCallback( + () => HostIsolationExceptionsApiClient.getInstance(http), + [http] + ); + + const { hasAccessToHostIsolationExceptions, isHostIsolationExceptionsAccessLoading } = + useHostIsolationExceptionsAccess( + canAccessHostIsolationExceptions, + canReadHostIsolationExceptions, + getHostIsolationExceptionsApiClientInstance + ); + + const visibleTabs = useMemo(() => { + return ARTIFACT_SUB_TABS.filter((tab) => { + if (tab === AdministrationSubTab.endpointExceptions) { + return endpointExceptionsMovedUnderManagement && canReadEndpointExceptions; + } + if (tab === AdministrationSubTab.trustedApps) { + return canReadTrustedApplications; + } + if (tab === AdministrationSubTab.trustedDevices) { + return trustedDevicesEnabled && canReadTrustedDevices; + } + if (tab === AdministrationSubTab.eventFilters) { + return canReadEventFilters; + } + if (tab === AdministrationSubTab.hostIsolationExceptions) { + return ( + canReadHostIsolationExceptions && + (isHostIsolationExceptionsAccessLoading || hasAccessToHostIsolationExceptions) + ); + } + if (tab === AdministrationSubTab.blocklist) { + return canReadBlocklist; + } + return true; + }); + }, [ + endpointExceptionsMovedUnderManagement, + trustedDevicesEnabled, + canReadEndpointExceptions, + canReadTrustedApplications, + canReadTrustedDevices, + canReadEventFilters, + canReadHostIsolationExceptions, + isHostIsolationExceptionsAccessLoading, + hasAccessToHostIsolationExceptions, + canReadBlocklist, + ]); + + const activeTab = useMemo( + () => getActiveTabFromPathname(location.pathname, visibleTabs), + [location.pathname, visibleTabs] + ); + + const selectedTabIndex = visibleTabs.findIndex((tab) => tab === activeTab); + const effectiveSelectedIndex = + visibleTabs.length > 0 && selectedTabIndex >= 0 ? selectedTabIndex : 0; + + const onTabClick = useCallback( + (tab: AdministrationSubTab) => { + history.push(getPathForTab(tab, visibleTabs)); + }, + [history, visibleTabs] + ); + + return ( + + + {visibleTabs.map((tab, index) => ( + onTabClick(tab)} + > + {getTabLabel(tab)} + + ))} + + + + {activeTab === AdministrationSubTab.endpointExceptions && } + {activeTab === AdministrationSubTab.trustedApps && } + {activeTab === AdministrationSubTab.trustedDevices && } + {activeTab === AdministrationSubTab.eventFilters && } + {activeTab === AdministrationSubTab.hostIsolationExceptions && + isHostIsolationExceptionsAccessLoading && ( + + + + + + )} + {activeTab === AdministrationSubTab.hostIsolationExceptions && + !isHostIsolationExceptionsAccessLoading && + !hasAccessToHostIsolationExceptions && ( + securitySolution.privileges} + /> + )} + {activeTab === AdministrationSubTab.hostIsolationExceptions && + !isHostIsolationExceptionsAccessLoading && + hasAccessToHostIsolationExceptions && } + {activeTab === AdministrationSubTab.blocklist && } + + + + ); +}); + +ArtifactsPage.displayName = 'ArtifactsPage'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/index.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/index.ts new file mode 100644 index 0000000000000..803e4a05eae96 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/artifacts/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 { ArtifactsPage } from './artifacts_page'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/blocklist/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/blocklist/index.tsx deleted file mode 100644 index 23ad93524638d..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/blocklist/index.tsx +++ /dev/null @@ -1,32 +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 { Routes, Route } from '@kbn/shared-ux-router'; -import React, { memo } from 'react'; -import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { MANAGEMENT_ROUTING_BLOCKLIST_PATH } from '../../common/constants'; -import { NotFoundPage } from '../../../app/404'; -import { Blocklist } from './view/blocklist'; -import { SecurityPageName } from '../../../app/types'; -import { SpyRoute } from '../../../common/utils/route/spy_routes'; - -/** - * Provides the routing container for the blocklist related views - */ -export const BlocklistContainer = memo(() => { - return ( - - - - - - - - ); -}); - -BlocklistContainer.displayName = 'BlocklistContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/index.tsx deleted file mode 100644 index fab58df92d9b0..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import { Routes, Route } from '@kbn/shared-ux-router'; -import { SecurityPageName } from '@kbn/deeplinks-security'; -import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { NotFoundPage } from '../../../app/404'; -import { SpyRoute } from '../../../common/utils/route/spy_routes'; -import { MANAGEMENT_ROUTING_ENDPOINT_EXCEPTIONS_PATH } from '../../common/constants'; -import { EndpointExceptions } from './view/endpoint_exceptions'; - -export const EndpointExceptionsContainer = memo(() => { - return ( - - - - - - - - ); -}); - -EndpointExceptionsContainer.displayName = 'EndpointExceptionsContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/index.tsx deleted file mode 100644 index b8d47c5eeb778..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/index.tsx +++ /dev/null @@ -1,21 +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 { Routes, Route } from '@kbn/shared-ux-router'; -import React from 'react'; -import { NotFoundPage } from '../../../app/404'; -import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../common/constants'; -import { EventFiltersList } from './view/event_filters_list'; - -export const EventFiltersContainer = () => { - return ( - - - - - ); -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx deleted file mode 100644 index 0f247ef5569cc..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx +++ /dev/null @@ -1,43 +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 { Routes, Route } from '@kbn/shared-ux-router'; -import React, { memo } from 'react'; -import { SecurityPageName } from '../../../../common/constants'; -import { useLinkAuthorized } from '../../../common/links'; -import { MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH } from '../../common/constants'; -import { NotFoundPage } from '../../../app/404'; -import { HostIsolationExceptionsList } from './view/host_isolation_exceptions_list'; -import { NoPrivilegesPage } from '../../../common/components/no_privileges'; - -/** - * Provides the routing container for the hosts related views - */ -export const HostIsolationExceptionsContainer = memo(() => { - const canAccessHostIsolationExceptionsLink = useLinkAuthorized( - SecurityPageName.hostIsolationExceptions - ); - if (!canAccessHostIsolationExceptionsLink) { - // TODO: Render a license/productType upsell page - return ( - securitySolution.privileges} /> - ); - } - - return ( - - - - - ); -}); - -HostIsolationExceptionsContainer.displayName = 'HostIsolationExceptionsContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/index.tsx index 766dad3307259..774602351ec7f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/index.tsx @@ -30,19 +30,14 @@ import { import { NotFoundPage } from '../../app/404'; import { EndpointsContainer } from './endpoint_hosts'; import { PolicyContainer } from './policy'; -import { TrustedAppsContainer } from './trusted_apps'; import { MANAGEMENT_PATH, SecurityPageName } from '../../../common/constants'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { EventFiltersContainer } from './event_filters'; import { getEndpointListPath } from '../common/routing'; import { useUserPrivileges } from '../../common/components/user_privileges'; -import { HostIsolationExceptionsContainer } from './host_isolation_exceptions'; -import { BlocklistContainer } from './blocklist'; import { ResponseActionsContainer } from './response_actions'; import { PrivilegedRoute } from '../components/privileged_route'; import { SecurityRoutePageWrapper } from '../../common/components/security_route_page_wrapper'; -import { TrustedDevicesContainer } from './trusted_devices'; -import { EndpointExceptionsContainer } from './endpoint_exceptions'; +import { ArtifactsPage } from './artifacts'; import { ScriptLibraryContainer } from './script_library'; const EndpointTelemetry = () => ( @@ -59,41 +54,6 @@ const PolicyTelemetry = () => ( ); -const EndpointExceptionsTelemetry = () => ( - - - - -); - -const TrustedAppTelemetry = () => ( - - - - -); - -const TrustedDevicesTelemetry = () => ( - - - - -); - -const EventFilterTelemetry = () => ( - - - - -); - -const HostIsolationExceptionsTelemetry = () => ( - - - - -); - const ResponseActionsTelemetry = () => ( @@ -173,36 +133,42 @@ export const ManagementContainer = memo(() => { {endpointExceptionsMovedUnderManagement && ( )} {trustedDevicesEnabled && ( )} { - return ( - - - - - ); -}); - -TrustedAppsContainer.displayName = 'TrustedAppsContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/index.tsx deleted file mode 100644 index 478e72cf13e06..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import { Routes, Route } from '@kbn/shared-ux-router'; -import { MANAGEMENT_ROUTING_TRUSTED_DEVICES_PATH } from '../../common/constants'; -import { NotFoundPage } from '../../../app/404'; -import { TrustedDevicesList } from './view/trusted_devices_list'; - -export const TrustedDevicesContainer = memo(() => { - return ( - - - - - ); -}); - -TrustedDevicesContainer.displayName = 'TrustedDevicesContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx index b8fe4cde9a1b4..e5e0cb0d45dc1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx @@ -604,7 +604,7 @@ export class Plugin implements IPlugin { - navigateFromHeaderTo(TRUSTED_APPS); - cy.url().should('include', TRUSTED_APPS_URL); + it('navigates to the Artifacts page from the Manage panel', () => { + navigateFromHeaderTo(ARTIFACTS); + cy.url().should('include', ADMINISTRATION_URL_PREFIX); }); - it('navigates to the Event Filters page', () => { - navigateFromHeaderTo(EVENT_FILTERS); + it('artifact tab deep links still resolve', () => { + visit(TRUSTED_APPS_URL); + cy.url().should('include', TRUSTED_APPS_URL); + visit(EVENT_FILTERS_URL); cy.url().should('include', EVENT_FILTERS_URL); - }); - it('navigates to the Blocklist page', () => { - navigateFromHeaderTo(BLOCKLIST); + visit(BLOCKLIST_URL); cy.url().should('include', BLOCKLIST_URL); }); it('navigates to the CSP Benchmarks page', () => { diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts index 2172b80afb3cf..b2081f92ba373 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts @@ -50,19 +50,8 @@ export const ENDPOINTS = '[data-test-subj="solutionSideNavPanelLink-endpoints"]' export const POLICIES = '[data-test-subj="solutionSideNavPanelLink-policy"]'; -export const TRUSTED_APPS = '[data-test-subj="solutionSideNavPanelLink-trusted_apps"]'; - -export const TRUSTED_DEVICES = '[data-test-subj="solutionSideNavPanelLink-trusted_devices"]'; - -export const EVENT_FILTERS = '[data-test-subj="solutionSideNavPanelLink-event_filters"]'; - -export const BLOCKLIST = '[data-test-subj="solutionSideNavPanelLink-blocklist"]'; - -export const HOST_ISOLATION_EXCEPTIONS = - '[data-test-subj="solutionSideNavPanelLink-host_isolation_exceptions"]'; - -export const ENDPOINT_EXCEPTIONS = - '[data-test-subj="solutionSideNavPanelLink-endpoint_exceptions"]'; +/** Unified Artifacts entry (replaces per-type trusted apps, event filters, blocklist, etc.) */ +export const ARTIFACTS = '[data-test-subj="solutionSideNavPanelLink-artifacts"]'; export const RESPONSE_ACTIONS_HISTORY = '[data-test-subj="solutionSideNavPanelLink-response_actions_history"]'; @@ -128,12 +117,8 @@ export const openNavigationPanelFor = (page: string) => { break; } case ENDPOINTS: - case TRUSTED_APPS: - case TRUSTED_DEVICES: - case EVENT_FILTERS: case POLICIES: - case ENDPOINT_EXCEPTIONS: - case BLOCKLIST: { + case ARTIFACTS: { panel = SETTINGS_PANEL_BTN; break; } diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/serverless_security_header.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/serverless_security_header.ts index c40265db3d4c3..b08f572408a86 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/serverless_security_header.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/serverless_security_header.ts @@ -47,18 +47,8 @@ export const ENDPOINTS = '[data-test-subj~="nav-item-id-endpoints"]'; export const POLICIES = '[data-test-subj~="nav-item-id-policy"]'; -export const TRUSTED_APPS = '[data-test-subj~="nav-item-id-trusted_apps"]'; - -export const TRUSTED_DEVICES = '[data-test-subj~="nav-item-id-trusted_devices"]'; - -export const EVENT_FILTERS = '[data-test-subj~="nav-item-id-event_filters"]'; - -export const BLOCKLIST = '[data-test-subj~="nav-item-id-blocklist"]'; - -export const HOST_ISOLATION_EXCEPTIONS = - '[data-test-subj~="nav-item-id-host_isolation_exceptions"]'; - -export const ENDPOINT_EXCEPTIONS = '[data-test-subj~="nav-item-id-endpoint_exceptions"]'; +/** Unified Artifacts entry (replaces per-type artifact nav items) */ +export const ARTIFACTS = '[data-test-subj~="nav-item-id-artifacts"]'; export const RESPONSE_ACTIONS_HISTORY = '[data-test-subj~="nav-item-id-response_actions_history"]'; @@ -104,7 +94,7 @@ export const openNavigationPanelFor = (pageName: string) => { break; } case FLEET: - case ENDPOINT_EXCEPTIONS: + case ARTIFACTS: case ENDPOINTS: { panel = ASSETS_PANEL_BTN; break; diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/navigation.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/navigation.ts index fd36d99df8a66..fb17299a37f7a 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/navigation.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/navigation.ts @@ -16,6 +16,8 @@ export const DASHBOARDS_URL = '/app/security/dashboards'; export const ASSETS_URL = '/app/security/assets'; export const ENDPOINTS_URL = '/app/security/administration/endpoints'; export const POLICIES_URL = '/app/security/administration/policy'; +/** Any artifact tab lives under this path prefix (trusted apps, event filters, blocklist, etc.) */ +export const ADMINISTRATION_URL_PREFIX = '/app/security/administration'; export const TRUSTED_APPS_URL = '/app/security/administration/trusted_apps'; export const EVENT_FILTERS_URL = '/app/security/administration/event_filters'; export const BLOCKLIST_URL = '/app/security/administration/blocklist'; diff --git a/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts b/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts index 37696f9f0b434..c28c699d6279d 100644 --- a/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts +++ b/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts @@ -239,9 +239,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { } await toasts.dismiss(); - // Title is shown after adding an item - expect(await testSubjects.getVisibleText('header-page-title')).to.equal(testData.title); - // Checks if fleet artifact has been updated correctly await checkFleetArtifacts( testData.fleetArtifact.identifier, @@ -275,9 +272,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await toasts.dismiss(); - // Title still shown after editing an item - expect(await testSubjects.getVisibleText('header-page-title')).to.equal(testData.title); - // Checks if fleet artifact has been updated correctly await checkFleetArtifacts( testData.fleetArtifact.identifier, diff --git a/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/trusted_apps_list.ts b/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/trusted_apps_list.ts index bbe143e44e35a..1449219e61a29 100644 --- a/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/trusted_apps_list.ts +++ b/x-pack/solutions/security/test/security_solution_endpoint/apps/integrations/trusted_apps_list.ts @@ -52,11 +52,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); await toasts.dismiss(); - // Title is shown after adding an item - expect(await testSubjects.getVisibleText('header-page-title')).to.equal( - 'Trusted applications' - ); - // Remove it await pageObjects.trustedApps.clickCardActionMenu(); await testSubjects.click('trustedAppsListPage-card-cardDeleteAction');