[Security Solutions] Add PLI authorisation for Investigation Guide (#162704)

## Summary

Add PLI authorization checks and Upselling message to Investigation.
*This PR restricts access to the features* and creates an updated
upselling hover message.
* It updates the Upselling registering to accept string because the
Markdown component doesn't accept components as hover messages.

### How to test it?
* Open timeline
* Go to the notes tab
* You should find an investigation guide button on the markdown editor
toolbar


#### ESS `yarn start`
* Run ESS with a basic license
  * It should not change
* Run ESS with a platinum
  * It should not change
  
#### Serverless `yarn serverless-security`
* Run Serverless with security essentials (serverless.security.yml)
   * It should show the Upselling message
```
xpack.serverless.security.productTypes:
  [
    { product_line: 'security', product_tier: 'essentials' }
  ]
```
* Run Serverless with security complete
(kibana/config/serverless.security.yml)
  * It should show the Investigation guide button
```
xpack.serverless.security.productTypes:
  [
    { product_line: 'security', product_tier: 'complete' },
  ]
 
 ```

<img width="1761" alt="Screenshot 2023-07-31 at 09 42 41" src="1a1e0313-7335-4a20-84e3-ec2d48f80b9c">



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
This commit is contained in:
Pablo Machado 2023-08-03 17:24:22 +02:00 committed by GitHub
parent ed7d6cf463
commit b6d94d7883
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 250 additions and 22 deletions

View file

@ -11,6 +11,11 @@ export enum AppFeatureSecurityKey {
*/
advancedInsights = 'advanced_insights',
/**
* Enables Investigation guide in Timeline
*/
investigationGuide = 'investigation_guide',
/**
* Enables access to the Endpoint List and associated views that allows management of hosts
* running endpoint security

View file

@ -21,6 +21,7 @@ import type { ContextShape } from '@elastic/eui/src/components/markdown_editor/m
import { useLicense } from '../../hooks/use_license';
import { uiPlugins, parsingPlugins, processingPlugins } from './plugins';
import { useUpsellingMessage } from '../../hooks/use_upselling';
interface MarkdownEditorProps {
onChange: (content: string) => void;
@ -73,9 +74,10 @@ const MarkdownEditorComponent = forwardRef<MarkdownEditorRef, MarkdownEditorProp
const licenseIsPlatinum = useLicense().isPlatinumPlus();
const insightsUpsellingMessage = useUpsellingMessage('investigation_guide');
const uiPluginsWithState = useMemo(() => {
return uiPlugins({ licenseIsPlatinum });
}, [licenseIsPlatinum]);
return uiPlugins({ licenseIsPlatinum, insightsUpsellingMessage });
}, [licenseIsPlatinum, insightsUpsellingMessage]);
// @ts-expect-error update types
useImperativeHandle(ref, () => {

View file

@ -10,7 +10,6 @@ import {
getDefaultEuiMarkdownProcessingPlugins,
getDefaultEuiMarkdownUiPlugins,
} from '@elastic/eui';
import * as timelineMarkdownPlugin from './timeline';
import * as osqueryMarkdownPlugin from './osquery';
import * as insightMarkdownPlugin from './insight';
@ -27,14 +26,30 @@ export const {
export const platinumOnlyPluginTokens = [insightMarkdownPlugin.insightPrefix];
export const uiPlugins = ({ licenseIsPlatinum }: { licenseIsPlatinum: boolean }) => {
export const uiPlugins = ({
licenseIsPlatinum,
insightsUpsellingMessage,
}: {
licenseIsPlatinum: boolean;
insightsUpsellingMessage: string | null;
}) => {
const currentPlugins = nonStatefulUiPlugins.map((plugin) => plugin.name);
const insightPluginWithLicense = insightMarkdownPlugin.plugin({ licenseIsPlatinum });
const insightPluginWithLicense = insightMarkdownPlugin.plugin({
licenseIsPlatinum,
insightsUpsellingMessage,
});
if (currentPlugins.includes(insightPluginWithLicense.name) === false) {
nonStatefulUiPlugins.push(timelineMarkdownPlugin.plugin);
nonStatefulUiPlugins.push(osqueryMarkdownPlugin.plugin);
nonStatefulUiPlugins.push(insightPluginWithLicense);
} else {
// When called for the second time we need to update insightMarkdownPlugin
const index = nonStatefulUiPlugins.findIndex(
(plugin) => plugin.name === insightPluginWithLicense.name
);
nonStatefulUiPlugins[index] = insightPluginWithLicense;
}
return nonStatefulUiPlugins;
};

View file

@ -16,7 +16,7 @@ import {
import { KibanaServices } from '../../../../lib/kibana';
import { licenseService } from '../../../../hooks/use_license';
import type { DefaultTimeRangeSetting } from '../../../../utils/default_date_settings';
import { renderer as Renderer } from '.';
import { plugin, renderer as Renderer } from '.';
import type { InvestigateInTimelineButtonProps } from '../../../event_details/table/investigate_in_timeline_button';
jest.mock('../../../../lib/kibana');
@ -130,3 +130,39 @@ describe('insight component renderer', () => {
});
});
});
describe('plugin', () => {
it('renders insightsUpsellingMessage when provided', () => {
const insightsUpsellingMessage = 'test message';
const result = plugin({ licenseIsPlatinum: false, insightsUpsellingMessage });
expect(result.button.label).toEqual(insightsUpsellingMessage);
});
it('disables the button when insightsUpsellingMessage is provided', () => {
const insightsUpsellingMessage = 'test message';
const result = plugin({ licenseIsPlatinum: false, insightsUpsellingMessage });
expect(result.button.isDisabled).toBeTruthy();
});
it('disables the button when license is not Platinum', () => {
const result = plugin({ licenseIsPlatinum: false, insightsUpsellingMessage: null });
expect(result.button.isDisabled).toBeTruthy();
});
it('show investigate message when license is Platinum', () => {
const result = plugin({ licenseIsPlatinum: true, insightsUpsellingMessage: null });
expect(result.button.label).toEqual('Investigate');
});
it('show upsell message when license is not Platinum', () => {
const result = plugin({ licenseIsPlatinum: false, insightsUpsellingMessage: null });
expect(result.button.label).toEqual(
'Upgrade to platinum to make use of insights in investigation guides'
);
});
});

View file

@ -541,13 +541,21 @@ const exampleInsight = `${insightPrefix}{
]
}}`;
export const plugin = ({ licenseIsPlatinum }: { licenseIsPlatinum: boolean }) => {
export const plugin = ({
licenseIsPlatinum,
insightsUpsellingMessage,
}: {
licenseIsPlatinum: boolean;
insightsUpsellingMessage: string | null;
}) => {
const label = licenseIsPlatinum ? i18n.INVESTIGATE : i18n.INSIGHT_UPSELL;
return {
name: 'insights',
button: {
label: licenseIsPlatinum ? i18n.INVESTIGATE : i18n.INIGHT_UPSELL,
label: insightsUpsellingMessage ?? label,
iconType: 'timelineWithArrow',
isDisabled: !licenseIsPlatinum,
isDisabled: !licenseIsPlatinum || !!insightsUpsellingMessage,
},
helpText: (
<div>

View file

@ -11,7 +11,7 @@ export const LABEL = i18n.translate('xpack.securitySolution.markdown.insight.lab
defaultMessage: 'Label',
});
export const INIGHT_UPSELL = i18n.translate('xpack.securitySolution.markdown.insight.upsell', {
export const INSIGHT_UPSELL = i18n.translate('xpack.securitySolution.markdown.insight.upsell', {
defaultMessage: 'Upgrade to platinum to make use of insights in investigation guides',
});

View file

@ -9,7 +9,7 @@ import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { SecurityPageName } from '../../../common';
import { UpsellingService } from '../lib/upsellings';
import { useUpsellingComponent, useUpsellingPage } from './use_upselling';
import { useUpsellingComponent, useUpsellingMessage, useUpsellingPage } from './use_upselling';
const mockUpselling = new UpsellingService();
@ -47,4 +47,24 @@ describe('use_upselling', () => {
const { result } = renderHook(() => useUpsellingPage(SecurityPageName.hosts));
expect(result.current).toBe(TestComponent);
});
test('useUpsellingMessage returns pages', () => {
const testMessage = 'test message';
mockUpselling.registerMessages({
investigation_guide: testMessage,
});
const { result } = renderHook(() => useUpsellingMessage('investigation_guide'));
expect(result.current).toBe(testMessage);
});
test('useUpsellingMessage returns null when upsellingMessageId not found', () => {
const emptyMessages = {};
mockUpselling.registerMessages(emptyMessages);
const { result } = renderHook(() =>
useUpsellingMessage('my_fake_message_id' as 'investigation_guide')
);
expect(result.current).toBe(null);
});
});

View file

@ -10,6 +10,7 @@ import useObservable from 'react-use/lib/useObservable';
import type { UpsellingSectionId } from '../lib/upsellings';
import { useKibana } from '../lib/kibana';
import type { SecurityPageName } from '../../../common';
import type { UpsellingMessageId } from '../lib/upsellings/types';
export const useUpsellingComponent = (id: UpsellingSectionId): React.ComponentType | null => {
const { upselling } = useKibana().services;
@ -18,6 +19,13 @@ export const useUpsellingComponent = (id: UpsellingSectionId): React.ComponentTy
return useMemo(() => upsellingSections?.get(id) ?? null, [id, upsellingSections]);
};
export const useUpsellingMessage = (id: UpsellingMessageId): string | null => {
const { upselling } = useKibana().services;
const upsellingMessages = useObservable(upselling.messages$);
return useMemo(() => upsellingMessages?.get(id) ?? null, [id, upsellingMessages]);
};
export const useUpsellingPage = (pageName: SecurityPageName): React.ComponentType | null => {
const { upselling } = useKibana().services;
const UpsellingPage = useMemo(() => upselling.getPageUpselling(pageName), [pageName, upselling]);

View file

@ -8,6 +8,9 @@
import type { SecurityPageName } from '../../../../common';
export type PageUpsellings = Partial<Record<SecurityPageName, React.ComponentType>>;
export type MessageUpsellings = Partial<Record<UpsellingMessageId, string>>;
export type SectionUpsellings = Partial<Record<UpsellingSectionId, React.ComponentType>>;
export type UpsellingSectionId = 'entity_analytics_panel';
export type UpsellingMessageId = 'investigation_guide';

View file

@ -34,6 +34,18 @@ describe('UpsellingService', () => {
expect(value.get(SecurityPageName.hosts)).toEqual(TestComponent);
});
it('registers messages', async () => {
const testMessage = 'test message';
const service = new UpsellingService();
service.registerMessages({
investigation_guide: testMessage,
});
const value = await firstValueFrom(service.messages$);
expect(value.get('investigation_guide')).toEqual(testMessage);
});
it('"isPageUpsellable" returns true when page is upsellable', () => {
const service = new UpsellingService();
service.registerPages({

View file

@ -8,24 +8,39 @@
import type { Observable } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import type { SecurityPageName } from '../../../../common';
import type { SectionUpsellings, PageUpsellings, UpsellingSectionId } from './types';
import type {
SectionUpsellings,
PageUpsellings,
UpsellingSectionId,
UpsellingMessageId,
MessageUpsellings,
} from './types';
export class UpsellingService {
private sections: Map<UpsellingSectionId, React.ComponentType>;
private pages: Map<SecurityPageName, React.ComponentType>;
private messages: Map<UpsellingMessageId, string>;
private messagesSubject$: BehaviorSubject<Map<UpsellingMessageId, string>>;
private sectionsSubject$: BehaviorSubject<Map<UpsellingSectionId, React.ComponentType>>;
private pagesSubject$: BehaviorSubject<Map<SecurityPageName, React.ComponentType>>;
public sections$: Observable<Map<UpsellingSectionId, React.ComponentType>>;
public pages$: Observable<Map<SecurityPageName, React.ComponentType>>;
public messages$: Observable<Map<UpsellingMessageId, string>>;
constructor() {
this.sections = new Map();
this.sectionsSubject$ = new BehaviorSubject(new Map());
this.sections$ = this.sectionsSubject$.asObservable();
this.pages = new Map();
this.pagesSubject$ = new BehaviorSubject(new Map());
this.pages$ = this.pagesSubject$.asObservable();
this.messages = new Map();
this.messagesSubject$ = new BehaviorSubject(new Map());
this.messages$ = this.messagesSubject$.asObservable();
}
registerSections(sections: SectionUpsellings) {
@ -42,6 +57,13 @@ export class UpsellingService {
this.pagesSubject$.next(this.pages);
}
registerMessages(messages: MessageUpsellings) {
Object.entries(messages).forEach(([messageId, component]) => {
this.messages.set(messageId as UpsellingMessageId, component);
});
this.messagesSubject$.next(this.messages);
}
isPageUpsellable(id: SecurityPageName) {
return this.pages.has(id);
}

View file

@ -10,6 +10,10 @@ import React from 'react';
import { NewNote } from './new_note';
jest.mock('../../../../common/hooks/use_upselling', () => ({
useUpsellingMessage: jest.fn(),
}));
describe('NewNote', () => {
const note = 'The contents of a new note';

View file

@ -44,6 +44,10 @@ jest.mock(
'../../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions'
);
jest.mock('../../../../common/hooks/use_upselling', () => ({
useUpsellingMessage: jest.fn(),
}));
jest.mock('../../../../common/components/user_privileges', () => {
return {
useUserPrivileges: () => ({

View file

@ -151,6 +151,16 @@ export const getSecurityAppFeaturesConfig = (
},
},
},
[AppFeatureSecurityKey.investigationGuide]: {
privileges: {
all: {
ui: ['investigation-guide'],
},
read: {
ui: ['investigation-guide'],
},
},
},
[AppFeatureSecurityKey.threatIntelligence]: {
privileges: {

View file

@ -17,6 +17,7 @@ export const PLI_APP_FEATURES: PliAppFeatures = {
essentials: [],
complete: [
AppFeatureKey.advancedInsights,
AppFeatureKey.investigationGuide,
AppFeatureKey.threatIntelligence,
AppFeatureKey.casesConnectors,
],

View file

@ -8,7 +8,7 @@
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { PLI_APP_FEATURES } from '../../../common/pli/pli_config';
export const useProductTypeByPLI = (requiredPLI: AppFeatureKey): string | null => {
export const getProductTypeByPLI = (requiredPLI: AppFeatureKey): string | null => {
if (PLI_APP_FEATURES.security.essentials.includes(requiredPLI)) {
return 'Security Essentials';
}

View file

@ -8,11 +8,11 @@
import React from 'react';
import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { useProductTypeByPLI } from '../hooks/use_product_type_by_pli';
import { getProductTypeByPLI } from '../hooks/use_product_type_by_pli';
export const GenericUpsellingPage: React.FC<{ requiredPLI: AppFeatureKey }> = React.memo(
function GenericUpsellingPage({ requiredPLI }) {
const productTypeRequired = useProductTypeByPLI(requiredPLI);
const productTypeRequired = getProductTypeByPLI(requiredPLI);
return (
<EuiEmptyPrompt

View file

@ -8,11 +8,11 @@
import React from 'react';
import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { useProductTypeByPLI } from '../hooks/use_product_type_by_pli';
import { getProductTypeByPLI } from '../hooks/use_product_type_by_pli';
export const GenericUpsellingSection: React.FC<{ requiredPLI: AppFeatureKey }> = React.memo(
function GenericUpsellingSection({ requiredPLI }) {
const productTypeRequired = useProductTypeByPLI(requiredPLI);
const productTypeRequired = getProductTypeByPLI(requiredPLI);
return (
<EuiEmptyPrompt

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { i18n } from '@kbn/i18n';
import { getProductTypeByPLI } from '../hooks/use_product_type_by_pli';
export const UPGRADE_INVESTIGATION_GUIDE = (productTypeRequired: string) =>
i18n.translate('xpack.securitySolutionServerless.markdown.insight.upsell', {
defaultMessage:
'Upgrade to {productTypeRequired} to make use of insights in investigation guides',
values: {
productTypeRequired,
},
});
export const investigationGuideUpselling = (requiredPLI: AppFeatureKey): string => {
const productTypeRequired = getProductTypeByPLI(requiredPLI);
return productTypeRequired ? UPGRADE_INVESTIGATION_GUIDE(productTypeRequired) : '';
};
// eslint-disable-next-line import/no-default-export
export { investigationGuideUpselling as default };

View file

@ -10,11 +10,11 @@ import { EuiEmptyPrompt, EuiIcon } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useProductTypeByPLI } from '../hooks/use_product_type_by_pli';
import { getProductTypeByPLI } from '../hooks/use_product_type_by_pli';
const ThreatIntelligencePaywall: React.FC<{ requiredPLI: AppFeatureKey }> = React.memo(
function PaywallComponent({ requiredPLI }) {
const productTypeRequired = useProductTypeByPLI(requiredPLI);
const productTypeRequired = getProductTypeByPLI(requiredPLI);
return (
<KibanaPageTemplate restrictWidth={false} contentBorder={false} grow={true}>

View file

@ -7,7 +7,12 @@
import type { UpsellingService } from '@kbn/security-solution-plugin/public';
import { ALL_APP_FEATURE_KEYS } from '@kbn/security-solution-plugin/common';
import { registerUpsellings, upsellingPages, upsellingSections } from './register_upsellings';
import {
registerUpsellings,
upsellingMessages,
upsellingPages,
upsellingSections,
} from './register_upsellings';
import { ProductLine, ProductTier } from '../../common/product';
import type { SecurityProductTypes } from '../../common/config';
@ -28,9 +33,11 @@ describe('registerUpsellings', () => {
const registerPages = jest.fn();
const registerSections = jest.fn();
const registerMessages = jest.fn();
const upselling = {
registerPages,
registerSections,
registerMessages,
} as unknown as UpsellingService;
registerUpsellings(upselling, allProductTypes);
@ -40,16 +47,22 @@ describe('registerUpsellings', () => {
expect(registerSections).toHaveBeenCalledTimes(1);
expect(registerSections).toHaveBeenCalledWith({});
expect(registerMessages).toHaveBeenCalledTimes(1);
expect(registerMessages).toHaveBeenCalledWith({});
});
it('should register all upsellings pages and sections when PLIs features are disabled', () => {
it('should register all upsellings pages, sections and messages when PLIs features are disabled', () => {
mockGetProductAppFeatures.mockReturnValue([]);
const registerPages = jest.fn();
const registerSections = jest.fn();
const registerMessages = jest.fn();
const upselling = {
registerPages,
registerSections,
registerMessages,
} as unknown as UpsellingService;
registerUpsellings(upselling, allProductTypes);
@ -65,5 +78,11 @@ describe('registerUpsellings', () => {
);
expect(registerSections).toHaveBeenCalledTimes(1);
expect(registerSections).toHaveBeenCalledWith(expectedSectionsObject);
const expectedMessagesObject = Object.fromEntries(
upsellingMessages.map(({ id }) => [id, expect.any(String)])
);
expect(registerMessages).toHaveBeenCalledTimes(1);
expect(registerMessages).toHaveBeenCalledWith(expectedMessagesObject);
});
});

View file

@ -12,17 +12,29 @@ import type {
UpsellingSectionId,
} from '@kbn/security-solution-plugin/public';
import React, { lazy } from 'react';
import type {
MessageUpsellings,
UpsellingMessageId,
} from '@kbn/security-solution-plugin/public/common/lib/upsellings/types';
import type { SecurityProductTypes } from '../../common/config';
import { getProductAppFeatures } from '../../common/pli/pli_features';
import investigationGuideUpselling from './pages/investigation_guide_upselling';
const ThreatIntelligencePaywallLazy = lazy(() => import('./pages/threat_intelligence_paywall'));
interface UpsellingsConfig {
pli: AppFeatureKey;
component: React.ComponentType;
}
interface UpsellingsMessageConfig {
pli: AppFeatureKey;
message: string;
id: UpsellingMessageId;
}
type UpsellingPages = Array<UpsellingsConfig & { pageName: SecurityPageName }>;
type UpsellingSections = Array<UpsellingsConfig & { id: UpsellingSectionId }>;
type UpsellingMessages = UpsellingsMessageConfig[];
export const registerUpsellings = (
upselling: UpsellingService,
@ -50,8 +62,19 @@ export const registerUpsellings = (
{}
);
const upsellingMessagesToRegister = upsellingMessages.reduce<MessageUpsellings>(
(messagesUpsellings, { id, pli, message }) => {
if (!enabledPLIsSet.has(pli)) {
messagesUpsellings[id] = message;
}
return messagesUpsellings;
},
{}
);
upselling.registerPages(upsellingPagesToRegister);
upselling.registerSections(upsellingSectionsToRegister);
upselling.registerMessages(upsellingMessagesToRegister);
};
// Upsellings for entire pages, linked to a SecurityPageName
@ -82,3 +105,12 @@ export const upsellingSections: UpsellingSections = [
// component: () => <GenericUpsellingSectionLazy requiredPLI={AppFeatureKey.advancedInsights} />,
// },
];
// Upsellings for sections, linked by arbitrary ids
export const upsellingMessages: UpsellingMessages = [
{
id: 'investigation_guide',
pli: AppFeatureKey.investigationGuide,
message: investigationGuideUpselling(AppFeatureKey.investigationGuide),
},
];