From 65f3cacab28c461742fcd1ea5d9c364c9717b994 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Fri, 10 Jun 2022 15:59:46 -0600 Subject: [PATCH 1/2] Adds privilege check --- .../integrations_description/index.tsx | 15 ++++++++++----- .../integrations_popover/index.tsx | 5 ++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx index 839ad0eedca1b..8b794f7cf8602 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx @@ -12,7 +12,7 @@ import { useInstalledIntegrations } from '../use_installed_integrations'; import { getInstalledRelatedIntegrations, getIntegrationLink, IntegrationDetails } from '../utils'; import { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common'; -import { useBasePath } from '../../../../../common/lib/kibana'; +import { useBasePath, useKibana } from '../../../../../common/lib/kibana'; import { ListItems } from '../../description_step/types'; import * as i18n from '../translations'; @@ -32,6 +32,9 @@ export const IntegrationDescriptionComponent: React.FC<{ integration: Integratio integration, }) => { const basePath = useBasePath(); + const services = useKibana().services; + const isSOMAvailable = services.application.capabilities.savedObjectsManagement.read; + const badgeInstalledColor = 'success'; const badgeUninstalledColor = '#E0E5EE'; const badgeColor = integration.is_installed ? badgeInstalledColor : badgeUninstalledColor; @@ -49,10 +52,12 @@ export const IntegrationDescriptionComponent: React.FC<{ integration: Integratio return ( {getIntegrationLink(integration, basePath)}{' '} - - {badgeText} - - {integration.is_installed && !integration.version_satisfied && ( + {isSOMAvailable && ( + + {badgeText} + + )} + {isSOMAvailable && integration.is_installed && !integration.version_satisfied && ( { const [isPopoverOpen, setPopoverOpen] = useState(false); const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); + const services = useKibana().services; + const isSOMAvailable = services.application.capabilities.savedObjectsManagement.read; const integrationDetails = getInstalledRelatedIntegrations( integrations, @@ -62,7 +65,7 @@ const IntegrationsPopoverComponent = ({ integrations }: IntegrationsPopoverProps const totalRelatedIntegrationsInstalled = integrationDetails.filter((i) => i.is_enabled).length; const badgeTitle = - allInstalledIntegrations != null + allInstalledIntegrations != null && isSOMAvailable ? `${totalRelatedIntegrationsInstalled}/${integrations.length} ${i18n.INTEGRATIONS_BADGE}` : `${integrations.length} ${i18n.INTEGRATIONS_BADGE}`; From 51c9c1f5aa1380611ddde919c0fcafe6c9eb443a Mon Sep 17 00:00:00 2001 From: Georgii Gorbachev Date: Tue, 14 Jun 2022 04:33:38 +0200 Subject: [PATCH 2/2] Add RBAC handling on the FE side --- ...s.test.tsx => integration_details.test.ts} | 100 ++++----- .../integration_details.ts | 195 ++++++++++++++++++ .../integration_privileges.ts | 10 + .../integrations_description/index.test.tsx | 37 ---- .../integrations_description/index.tsx | 75 ++----- .../integration_link.tsx | 33 +++ .../integration_status_badge.tsx | 58 ++++++ .../integration_version_mismatch_icon.tsx | 50 +++++ .../integrations_popover/index.tsx | 49 ++--- .../rules/related_integrations/mock.ts | 37 ---- .../related_integrations/translations.ts | 6 +- .../use_integration_privileges.ts | 28 +++ .../use_related_integrations.ts | 41 ++++ .../rules/related_integrations/utils.tsx | 113 ---------- .../rules/all/use_columns.tsx | 2 +- 15 files changed, 497 insertions(+), 337 deletions(-) rename x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/{utils.test.tsx => integration_details.test.ts} (54%) create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_privileges.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_link.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_version_mismatch_icon.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integration_privileges.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts similarity index 54% rename from x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx rename to x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts index 0cce6f17691c7..58ad887013468 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts @@ -5,59 +5,25 @@ * 2.0. */ -import { - integrationDetailsEnabled, - integrationDetailsInstalled, - integrationDetailsUninstalled, -} from './mock'; -import { render } from '@testing-library/react'; -import { getInstalledRelatedIntegrations, getIntegrationLink } from './utils'; +import { calculateIntegrationDetails } from './integration_details'; +import { IntegrationPrivileges } from './integration_privileges'; -describe('Related Integrations Utilities', () => { - describe('#getIntegrationLink', () => { - describe('it returns a correctly formatted integrations link', () => { - test('given an uninstalled integrationDetails', () => { - const link = getIntegrationLink(integrationDetailsUninstalled, 'http://localhost'); - const { container } = render(link); +describe('Integration Details', () => { + describe('calculateIntegrationDetails', () => { + const stubPrivileges: IntegrationPrivileges = { + canReadInstalledIntegrations: true, + }; - expect(container.firstChild).toHaveProperty( - 'href', - 'http://localhost/app/integrations/detail/test-1.2.3/overview?integration=integration' - ); - }); - - test('given an installed integrationDetails', () => { - const link = getIntegrationLink(integrationDetailsInstalled, 'http://localhost'); - const { container } = render(link); - - expect(container.firstChild).toHaveProperty( - 'href', - 'http://localhost/app/integrations/detail/test-1.2.3/overview?integration=integration' - ); - }); - - test('given an enabled integrationDetails with an unsatisfied version', () => { - const link = getIntegrationLink(integrationDetailsEnabled, 'http://localhost'); - const { container } = render(link); - - expect(container.firstChild).toHaveProperty( - 'href', - 'http://localhost/app/integrations/detail/test-1.3.3/overview?integration=integration' - ); - }); - }); - }); - - describe('#getInstalledRelatedIntegrations', () => { test('it returns a the correct integrationDetails', () => { - const integrationDetails = getInstalledRelatedIntegrations([], []); + const integrationDetails = calculateIntegrationDetails(stubPrivileges, [], []); expect(integrationDetails.length).toEqual(0); }); describe('version is correctly computed', () => { test('Unknown integration that does not exist', () => { - const integrationDetails = getInstalledRelatedIntegrations( + const integrationDetails = calculateIntegrationDetails( + stubPrivileges, [ { package: 'foo1', @@ -75,13 +41,14 @@ describe('Related Integrations Utilities', () => { [] ); - expect(integrationDetails[0].target_version).toEqual('1.2.3'); - expect(integrationDetails[1].target_version).toEqual('1.2.3'); - expect(integrationDetails[2].target_version).toEqual('1.2.0'); + expect(integrationDetails[0].targetVersion).toEqual('1.2.3'); + expect(integrationDetails[1].targetVersion).toEqual('1.2.3'); + expect(integrationDetails[2].targetVersion).toEqual('1.2.0'); }); test('Integration that is not installed', () => { - const integrationDetails = getInstalledRelatedIntegrations( + const integrationDetails = calculateIntegrationDetails( + stubPrivileges, [ { package: 'aws', @@ -96,12 +63,13 @@ describe('Related Integrations Utilities', () => { [] ); - expect(integrationDetails[0].target_version).toEqual('1.2.3'); - expect(integrationDetails[1].target_version).toEqual('1.2.3'); + expect(integrationDetails[0].targetVersion).toEqual('1.2.3'); + expect(integrationDetails[1].targetVersion).toEqual('1.2.3'); }); test('Integration that is installed, and its version matches required version', () => { - const integrationDetails = getInstalledRelatedIntegrations( + const integrationDetails = calculateIntegrationDetails( + stubPrivileges, [ { package: 'aws', @@ -131,15 +99,22 @@ describe('Related Integrations Utilities', () => { ] ); - // Since version is satisfied, we check `package_version` - expect(integrationDetails[0].version_satisfied).toEqual(true); - expect(integrationDetails[0].package_version).toEqual('1.3.0'); - expect(integrationDetails[1].version_satisfied).toEqual(true); - expect(integrationDetails[1].package_version).toEqual('1.2.5'); + expect(integrationDetails[0].installationStatus.isKnown).toEqual(true); + if (integrationDetails[0].installationStatus.isKnown) { + expect(integrationDetails[0].installationStatus.isVersionMismatch).toEqual(false); + expect(integrationDetails[0].installationStatus.installedVersion).toEqual('1.3.0'); + } + + expect(integrationDetails[1].installationStatus.isKnown).toEqual(true); + if (integrationDetails[1].installationStatus.isKnown) { + expect(integrationDetails[1].installationStatus.isVersionMismatch).toEqual(false); + expect(integrationDetails[1].installationStatus.installedVersion).toEqual('1.2.5'); + } }); test('Integration that is installed, and its version is less than required version', () => { - const integrationDetails = getInstalledRelatedIntegrations( + const integrationDetails = calculateIntegrationDetails( + stubPrivileges, [ { package: 'aws', @@ -169,12 +144,13 @@ describe('Related Integrations Utilities', () => { ] ); - expect(integrationDetails[0].target_version).toEqual('1.2.3'); - expect(integrationDetails[1].target_version).toEqual('1.2.3'); + expect(integrationDetails[0].targetVersion).toEqual('1.2.3'); + expect(integrationDetails[1].targetVersion).toEqual('1.2.3'); }); test('Integration that is installed, and its version is greater than required version', () => { - const integrationDetails = getInstalledRelatedIntegrations( + const integrationDetails = calculateIntegrationDetails( + stubPrivileges, [ { package: 'aws', @@ -204,8 +180,8 @@ describe('Related Integrations Utilities', () => { ] ); - expect(integrationDetails[0].target_version).toEqual('1.2.3'); - expect(integrationDetails[1].target_version).toEqual('1.2.3'); + expect(integrationDetails[0].targetVersion).toEqual('1.2.3'); + expect(integrationDetails[1].targetVersion).toEqual('1.2.3'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts new file mode 100644 index 0000000000000..177a7921baf55 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { capitalize } from 'lodash'; +import semver from 'semver'; +import { + InstalledIntegration, + InstalledIntegrationArray, + RelatedIntegration, + RelatedIntegrationArray, +} from '../../../../../common/detection_engine/schemas/common'; +import { IntegrationPrivileges } from './integration_privileges'; + +export interface IntegrationDetails { + packageName: string; + integrationName: string | null; + integrationTitle: string; + + requiredVersion: string; + targetVersion: string; + targetUrl: string; + + installationStatus: KnownInstallationStatus | UnknownInstallationStatus; +} + +export interface KnownInstallationStatus { + isKnown: true; + isInstalled: boolean; + isEnabled: boolean; + isVersionMismatch: boolean; + installedVersion: string; +} + +export interface UnknownInstallationStatus { + isKnown: false; +} + +/** + * Given an array of integrations and an array of installed integrations this will return an + * array of integrations augmented with install details like targetVersion, and `version_satisfied` + * has. + */ +export const calculateIntegrationDetails = ( + privileges: IntegrationPrivileges, + relatedIntegrations: RelatedIntegrationArray, + installedIntegrations: InstalledIntegrationArray | undefined +): IntegrationDetails[] => { + const integrationMatches = findIntegrationMatches(relatedIntegrations, installedIntegrations); + const integrationDetails = integrationMatches.map((integration) => { + return createIntegrationDetails(integration, privileges); + }); + + return integrationDetails.sort((a, b) => { + return a.integrationTitle.localeCompare(b.integrationTitle); + }); +}; + +interface IntegrationMatch { + related: RelatedIntegration; + installed: InstalledIntegration | null; + isLoaded: boolean; +} + +const findIntegrationMatches = ( + relatedIntegrations: RelatedIntegrationArray, + installedIntegrations: InstalledIntegrationArray | undefined +): IntegrationMatch[] => { + return relatedIntegrations.map((ri: RelatedIntegration) => { + if (installedIntegrations == null) { + return { + related: ri, + installed: null, + isLoaded: false, + }; + } else { + const match = installedIntegrations.find( + (ii: InstalledIntegration) => + ii.package_name === ri.package && ii?.integration_name === ri?.integration + ); + return { + related: ri, + installed: match ?? null, + isLoaded: true, + }; + } + }); +}; + +const createIntegrationDetails = ( + integration: IntegrationMatch, + privileges: IntegrationPrivileges +): IntegrationDetails => { + const { related, installed, isLoaded } = integration; + const { canReadInstalledIntegrations } = privileges; + + const packageName = related.package; + const integrationName = related.integration ?? null; + const requiredVersion = related.version; + + // We don't know whether the integration is installed or not. + if (!canReadInstalledIntegrations || !isLoaded) { + const integrationTitle = getCapitalizedTitle(packageName, integrationName); + const targetVersion = getMinimumConcreteVersionMatchingSemver(requiredVersion); + const targetUrl = buildTargetUrl(packageName, integrationName, targetVersion); + + return { + packageName, + integrationName, + integrationTitle, + requiredVersion, + targetVersion, + targetUrl, + installationStatus: { + isKnown: false, + }, + }; + } + + // We know that the integration is not installed + if (installed == null) { + const integrationTitle = getCapitalizedTitle(packageName, integrationName); + const targetVersion = getMinimumConcreteVersionMatchingSemver(requiredVersion); + const targetUrl = buildTargetUrl(packageName, integrationName, targetVersion); + + return { + packageName, + integrationName, + integrationTitle, + requiredVersion, + targetVersion, + targetUrl, + installationStatus: { + isKnown: true, + isInstalled: false, + isEnabled: false, + isVersionMismatch: false, + installedVersion: '', + }, + }; + } + + // We know that the integration is installed + { + const integrationTitle = installed.integration_title ?? installed.package_title; + + // Version check e.g. installed version `1.2.3` satisfies required version `~1.2.1` + const installedVersion = installed.package_version; + const isVersionSatisfied = semver.satisfies(installedVersion, requiredVersion); + const targetVersion = isVersionSatisfied + ? installedVersion + : getMinimumConcreteVersionMatchingSemver(requiredVersion); + + const targetUrl = buildTargetUrl(packageName, integrationName, targetVersion); + + return { + packageName, + integrationName, + integrationTitle, + requiredVersion, + targetVersion, + targetUrl, + installationStatus: { + isKnown: true, + isInstalled: true, + isEnabled: installed.is_enabled, + isVersionMismatch: !isVersionSatisfied, + installedVersion, + }, + }; + } +}; + +const getCapitalizedTitle = (packageName: string, integrationName: string | null): string => { + return integrationName == null + ? `${capitalize(packageName)}` + : `${capitalize(packageName)} ${capitalize(integrationName)}`; +}; + +const getMinimumConcreteVersionMatchingSemver = (semverString: string): string => { + return semver.valid(semver.coerce(semverString)) ?? ''; +}; + +const buildTargetUrl = ( + packageName: string, + integrationName: string | null, + targetVersion: string +): string => { + const packageSegment = targetVersion ? `${packageName}-${targetVersion}` : packageName; + const query = integrationName ? `?integration=${integrationName}` : ''; + return `app/integrations/detail/${packageSegment}/overview${query}`; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_privileges.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_privileges.ts new file mode 100644 index 0000000000000..0f6df986e55b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_privileges.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface IntegrationPrivileges { + canReadInstalledIntegrations: boolean; +} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx deleted file mode 100644 index d337da3cb60bd..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { installedIntegrationsBase, relatedIntegrations } from '../mock'; -import { useInstalledIntegrations } from '../use_installed_integrations'; -import { getInstalledRelatedIntegrations } from '../utils'; -import { IntegrationDescription } from '.'; -import { render, screen } from '@testing-library/react'; - -jest.mock('../../../../../common/lib/kibana'); -jest.mock('../use_installed_integrations'); - -const mockUseInstalledIntegrations = useInstalledIntegrations as jest.Mock; -mockUseInstalledIntegrations.mockReturnValue({ - data: installedIntegrationsBase, - isLoading: false, - isFetching: false, -}); - -describe('IntegrationDescription', () => { - test('Shows total events returned', () => { - const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); - - const integrationDetails = getInstalledRelatedIntegrations( - relatedIntegrations, - allInstalledIntegrations - ); - render(); - expect(screen.getByTestId('integrationLink')).toHaveTextContent('Aws Cloudtrail'); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx index 8b794f7cf8602..79c8105b39135 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx @@ -7,68 +7,28 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiBadge, EuiIconTip, EuiToolTip } from '@elastic/eui'; -import { useInstalledIntegrations } from '../use_installed_integrations'; -import { getInstalledRelatedIntegrations, getIntegrationLink, IntegrationDetails } from '../utils'; import { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common'; -import { useBasePath, useKibana } from '../../../../../common/lib/kibana'; import { ListItems } from '../../description_step/types'; -import * as i18n from '../translations'; +import { IntegrationDetails } from '../integration_details'; +import { useRelatedIntegrations } from '../use_related_integrations'; + +import { IntegrationLink } from './integration_link'; +import { IntegrationStatusBadge } from './integration_status_badge'; +import { IntegrationVersionMismatchIcon } from './integration_version_mismatch_icon'; const Wrapper = styled.div` overflow: hidden; `; -const PaddedBadge = styled(EuiBadge)` - margin-left: 5px; -`; - -const VersionWarningIconContainer = styled.span` - margin-left: 5px; -`; - export const IntegrationDescriptionComponent: React.FC<{ integration: IntegrationDetails }> = ({ integration, }) => { - const basePath = useBasePath(); - const services = useKibana().services; - const isSOMAvailable = services.application.capabilities.savedObjectsManagement.read; - - const badgeInstalledColor = 'success'; - const badgeUninstalledColor = '#E0E5EE'; - const badgeColor = integration.is_installed ? badgeInstalledColor : badgeUninstalledColor; - const badgeTooltip = integration.is_installed - ? integration.is_enabled - ? i18n.INTEGRATIONS_ENABLED_TOOLTIP - : i18n.INTEGRATIONS_INSTALLED_TOOLTIP - : i18n.INTEGRATIONS_UNINSTALLED_TOOLTIP; - const badgeText = integration.is_installed - ? integration.is_enabled - ? i18n.INTEGRATIONS_ENABLED - : i18n.INTEGRATIONS_INSTALLED - : i18n.INTEGRATIONS_UNINSTALLED; - return ( - {getIntegrationLink(integration, basePath)}{' '} - {isSOMAvailable && ( - - {badgeText} - - )} - {isSOMAvailable && integration.is_installed && !integration.version_satisfied && ( - - - - )} + {' '} + + ); }; @@ -76,20 +36,15 @@ export const IntegrationDescriptionComponent: React.FC<{ integration: Integratio export const IntegrationDescription = React.memo(IntegrationDescriptionComponent); export const RelatedIntegrationsDescription: React.FC<{ - integrations: RelatedIntegrationArray; -}> = ({ integrations }) => { - const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); - - const integrationDetails = getInstalledRelatedIntegrations( - integrations, - allInstalledIntegrations - ); + relatedIntegrations: RelatedIntegrationArray; +}> = ({ relatedIntegrations }) => { + const { integrations } = useRelatedIntegrations(relatedIntegrations); return ( <> - {integrationDetails.map((integration, index) => ( + {integrations.map((integration, index) => ( ))} @@ -108,7 +63,7 @@ export const buildRelatedIntegrationsDescription = ( return [ { title: label, - description: , + description: , }, ]; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_link.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_link.tsx new file mode 100644 index 0000000000000..c79435f49e212 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_link.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { useBasePath } from '../../../../../common/lib/kibana'; +import { IntegrationDetails } from '../integration_details'; + +interface IntegrationLinkProps { + integration: IntegrationDetails; +} + +const IntegrationLinkComponent: React.FC = ({ integration }) => { + const basePath = useBasePath(); + const linkText = integration.integrationTitle; + const linkUrl = `${basePath}/${integration.targetUrl}`; + + return ( + + {linkText} + + ); +}; + +/** + * Renders an `EuiLink` that will link to a given package/integration/version page within fleet. + */ +export const IntegrationLink = React.memo(IntegrationLinkComponent); +IntegrationLink.displayName = 'IntegrationLink'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx new file mode 100644 index 0000000000000..ca17144133dbd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { IntegrationDetails } from '../integration_details'; +import * as i18n from '../translations'; + +const PaddedBadge = styled(EuiBadge)` + margin-left: 5px; +`; + +interface IntegrationStatusBadgeProps { + integration: IntegrationDetails; +} + +const IntegrationStatusBadgeComponent: React.FC = ({ + integration, +}) => { + const { installationStatus } = integration; + + if (!installationStatus.isKnown) { + return null; + } + + const { isInstalled, isEnabled } = installationStatus; + + const badgeInstalledColor = 'success'; + const badgeUninstalledColor = '#E0E5EE'; + const badgeColor = isInstalled ? badgeInstalledColor : badgeUninstalledColor; + + const badgeTooltip = isInstalled + ? isEnabled + ? i18n.INTEGRATIONS_ENABLED_TOOLTIP + : i18n.INTEGRATIONS_INSTALLED_TOOLTIP + : i18n.INTEGRATIONS_UNINSTALLED_TOOLTIP; + + const badgeText = isInstalled + ? isEnabled + ? i18n.INTEGRATIONS_ENABLED + : i18n.INTEGRATIONS_INSTALLED + : i18n.INTEGRATIONS_UNINSTALLED; + + return ( + + {badgeText} + + ); +}; + +export const IntegrationStatusBadge = React.memo(IntegrationStatusBadgeComponent); +IntegrationStatusBadge.displayName = 'IntegrationStatusBadge'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_version_mismatch_icon.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_version_mismatch_icon.tsx new file mode 100644 index 0000000000000..0a933f7bc9417 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_version_mismatch_icon.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiIconTip } from '@elastic/eui'; +import { IntegrationDetails } from '../integration_details'; +import * as i18n from '../translations'; + +const VersionWarningIconContainer = styled.span` + margin-left: 5px; +`; + +interface IntegrationVersionMismatchIconProps { + integration: IntegrationDetails; +} + +const IntegrationVersionMismatchIconComponent: React.FC = ({ + integration, +}) => { + const { installationStatus } = integration; + + if ( + !installationStatus.isKnown || + !installationStatus.isInstalled || + !installationStatus.isVersionMismatch + ) { + return null; + } + + return ( + + + + ); +}; + +export const IntegrationVersionMismatchIcon = React.memo(IntegrationVersionMismatchIconComponent); +IntegrationVersionMismatchIcon.displayName = 'IntegrationVersionMismatchIcon'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx index c137566ebec80..0416bfa219859 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; import { EuiPopover, EuiBadge, @@ -14,17 +15,15 @@ import { EuiText, EuiSpacer, } from '@elastic/eui'; -import styled from 'styled-components'; -import { useKibana } from '../../../../../common/lib/kibana'; -import { IntegrationDescription } from '../integrations_description'; -import { getInstalledRelatedIntegrations } from '../utils'; -import { useInstalledIntegrations } from '../use_installed_integrations'; + import type { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common'; +import { IntegrationDescription } from '../integrations_description'; +import { useRelatedIntegrations } from '../use_related_integrations'; import * as i18n from '../translations'; export interface IntegrationsPopoverProps { - integrations: RelatedIntegrationArray; + relatedIntegrations: RelatedIntegrationArray; } const IntegrationsPopoverWrapper = styled(EuiFlexGroup)` @@ -52,22 +51,24 @@ const IntegrationListItem = styled('li')` * Component to render installed and available integrations * @param integrations - array of integrations to display */ -const IntegrationsPopoverComponent = ({ integrations }: IntegrationsPopoverProps) => { +const IntegrationsPopoverComponent = ({ relatedIntegrations }: IntegrationsPopoverProps) => { const [isPopoverOpen, setPopoverOpen] = useState(false); - const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); - const services = useKibana().services; - const isSOMAvailable = services.application.capabilities.savedObjectsManagement.read; + const { integrations, privileges, isLoaded } = useRelatedIntegrations(relatedIntegrations); - const integrationDetails = getInstalledRelatedIntegrations( - integrations, - allInstalledIntegrations - ); + const enabledIntegrations = useMemo(() => { + return integrations.filter( + (i) => i.installationStatus.isKnown && i.installationStatus.isEnabled + ); + }, [integrations]); + + const numIntegrations = integrations.length; + const numIntegrationsEnabled = enabledIntegrations.length; - const totalRelatedIntegrationsInstalled = integrationDetails.filter((i) => i.is_enabled).length; - const badgeTitle = - allInstalledIntegrations != null && isSOMAvailable - ? `${totalRelatedIntegrationsInstalled}/${integrations.length} ${i18n.INTEGRATIONS_BADGE}` - : `${integrations.length} ${i18n.INTEGRATIONS_BADGE}`; + const badgeTitle = useMemo(() => { + return privileges.canReadInstalledIntegrations && isLoaded + ? `${numIntegrationsEnabled}/${numIntegrations} ${i18n.INTEGRATIONS_BADGE}` + : `${numIntegrations} ${i18n.INTEGRATIONS_BADGE}`; + }, [privileges, isLoaded, numIntegrations, numIntegrationsEnabled]); return ( - {i18n.INTEGRATIONS_POPOVER_TITLE(integrations.length)} + {i18n.INTEGRATIONS_POPOVER_TITLE(numIntegrations)} - {i18n.INTEGRATIONS_POPOVER_DESCRIPTION(integrations.length)} + {i18n.INTEGRATIONS_POPOVER_DESCRIPTION(numIntegrations)}
    - {integrationDetails.map((integration, index) => ( - + {integrations.map((integration, index) => ( + ))} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts index 548839304ed03..2f7abdad4bb46 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { IntegrationDetails } from './utils'; import { InstalledIntegrationArray, RelatedIntegrationArray, @@ -62,39 +61,3 @@ export const installedIntegrationsAWSCloudwatch: InstalledIntegrationArray = [ is_enabled: true, }, ]; - -export const integrationDetailsUninstalled: IntegrationDetails = { - package_name: 'test', - package_title: 'Test', - package_version: '1.2.3', - integration_name: 'integration', - integration_title: 'Integration', - is_enabled: false, - is_installed: false, - target_version: '1.2.3', - version_satisfied: false, -}; - -export const integrationDetailsInstalled: IntegrationDetails = { - package_name: 'test', - package_title: 'Test', - package_version: '1.2.3', - integration_name: 'integration', - integration_title: 'Integration', - is_enabled: false, - is_installed: true, - target_version: '1.2.3', - version_satisfied: true, -}; - -export const integrationDetailsEnabled: IntegrationDetails = { - package_name: 'test', - package_title: 'Test', - package_version: '1.1.3', - integration_name: 'integration', - integration_title: 'Integration', - is_enabled: true, - is_installed: true, - target_version: '1.3.3', - version_satisfied: false, -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts index b02b84221c315..8e46cdf2d447b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts @@ -75,14 +75,14 @@ export const INTEGRATIONS_POPOVER_DESCRIPTION = (integrationsCount: number) => export const INTEGRATIONS_INSTALLED_VERSION_TOOLTIP = ( installedVersion: string, - targetVersion: string + requiredVersion: string ) => i18n.translate( 'xpack.securitySolution.detectionEngine.relatedIntegrations.popoverDescriptionInstalledVersionTooltip', { - values: { installedVersion, targetVersion }, + values: { installedVersion, requiredVersion }, defaultMessage: - 'Version mismatch -- please resolve! Installed version `{installedVersion}` when target version `{targetVersion}`', + 'Version mismatch -- please resolve! Installed version `{installedVersion}` when required version `{requiredVersion}`', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integration_privileges.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integration_privileges.ts new file mode 100644 index 0000000000000..8a94756d16b0d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integration_privileges.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from '../../../../common/lib/kibana'; +import { IntegrationPrivileges } from './integration_privileges'; + +export const useIntegrationPrivileges = (): IntegrationPrivileges => { + const services = useKibana().services; + + const hasReadPrivilegesFor: Record = { + savedObjectsManagement: Boolean(services.application.capabilities.savedObjectsManagement.read), + integrations: Boolean(services.application.capabilities.fleet.read), + fleet: Boolean(services.application.capabilities.fleetv2.read), + }; + + const canReadInstalledIntegrations = + hasReadPrivilegesFor.savedObjectsManagement || + hasReadPrivilegesFor.integrations || + hasReadPrivilegesFor.fleet; + + return { + canReadInstalledIntegrations, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts new file mode 100644 index 0000000000000..4f1d467420774 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import { RelatedIntegrationArray } from '../../../../../common/detection_engine/schemas/common'; +import { calculateIntegrationDetails, IntegrationDetails } from './integration_details'; +import { IntegrationPrivileges } from './integration_privileges'; +import { useIntegrationPrivileges } from './use_integration_privileges'; +import { useInstalledIntegrations } from './use_installed_integrations'; + +export interface UseRelatedIntegrationsResult { + integrations: IntegrationDetails[]; + privileges: IntegrationPrivileges; + isLoaded: boolean; +} + +export const useRelatedIntegrations = ( + relatedIntegrations: RelatedIntegrationArray +): UseRelatedIntegrationsResult => { + const privileges = useIntegrationPrivileges(); + const { data: installedIntegrations } = useInstalledIntegrations({ packages: [] }); + + return useMemo(() => { + const integrationDetails = calculateIntegrationDetails( + privileges, + relatedIntegrations, + installedIntegrations + ); + + return { + integrations: integrationDetails, + privileges, + isLoaded: installedIntegrations != null, + }; + }, [privileges, relatedIntegrations, installedIntegrations]); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx deleted file mode 100644 index bc52c12f31c3b..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx +++ /dev/null @@ -1,113 +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 { EuiLink } from '@elastic/eui'; -import { capitalize } from 'lodash'; -import React from 'react'; -import semver from 'semver'; -import { - InstalledIntegration, - InstalledIntegrationArray, - RelatedIntegration, - RelatedIntegrationArray, -} from '../../../../../common/detection_engine/schemas/common'; - -/** - * Returns an `EuiLink` that will link to a given package/integration/version page within fleet - * - * @param integration IntegrationDetails describing a package/integrations installed state - * @param basePath kbn basepath for composing the fleet URL - */ -export const getIntegrationLink = (integration: IntegrationDetails, basePath: string) => { - const packageName = integration.package_name; - const integrationName = integration.integration_name; - const integrationTitle = integration.integration_title ?? integration.package_title; - const version = integration.version_satisfied - ? integration.package_version - : integration.target_version; - - const integrationURL = - version !== '' - ? `${basePath}/app/integrations/detail/${packageName}-${version}/overview${ - integrationName ? `?integration=${integrationName}` : '' - }` - : `${basePath}/app/integrations/detail/${packageName}`; - return ( - - {integrationTitle} - - ); -}; - -export interface IntegrationDetails extends InstalledIntegration { - target_version: string; - version_satisfied: boolean; - is_installed: boolean; -} - -/** - * Given an array of integrations and an array of installed integrations this will return an - * array of integrations augmented with install details like targetVersion, and `version_satisfied` - * has - * @param integrations - * @param installedIntegrations - */ -export const getInstalledRelatedIntegrations = ( - integrations: RelatedIntegrationArray, - installedIntegrations: InstalledIntegrationArray | undefined -): IntegrationDetails[] => { - const integrationDetails: IntegrationDetails[] = []; - - integrations.forEach((i: RelatedIntegration) => { - const match = installedIntegrations?.find( - (installed) => - installed.package_name === i.package && installed?.integration_name === i?.integration - ); - - if (match != null) { - // Version check e.g. fleet match `1.2.3` satisfies rule dependency `~1.2.1` - const versionSatisfied = semver.satisfies(match.package_version, i.version); - const packageVersion = versionSatisfied - ? i.version - : semver.valid(semver.coerce(i.version)) ?? ''; - integrationDetails.push({ - ...match, - target_version: packageVersion, - version_satisfied: versionSatisfied, - is_installed: true, - }); - } else { - const packageVersion = semver.valid(semver.coerce(i.version)) ?? ''; - // TODO: Add `title` to RelatedIntegration (or fetch from Fleet API) so we can accurately display the integration pretty name - const integrationTitle = - i.integration != null ? `${capitalize(i.package)} ${capitalize(i.integration)}` : undefined; - integrationDetails.push({ - package_name: i.package, - package_title: capitalize(i.package), - package_version: packageVersion, - integration_name: i.integration, - integration_title: integrationTitle, - target_version: packageVersion, - version_satisfied: false, - is_enabled: false, - is_installed: false, - }); - } - }); - - return integrationDetails.sort((a, b) => { - if (a.integration_title != null && b.integration_title != null) { - return a.integration_title.localeCompare(b.integration_title); - } else if (a.integration_title != null) { - return a.integration_title.localeCompare(b.package_title); - } else if (b.integration_title != null) { - return a.package_title.localeCompare(b.integration_title); - } else { - return a.package_title.localeCompare(b.package_title); - } - }); -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index d46b0878b309a..bb3f3dc8cb481 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -168,7 +168,7 @@ const INTEGRATIONS_COLUMN: TableColumn = { return null; } - return ; + return ; }, width: '143px', truncateText: true,