Skip to content

Commit

Permalink
[Security Solutions] Add PLI authorisation for Investigation Guide (#…
Browse files Browse the repository at this point in the history
…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="https://github.com/elastic/kibana/assets/1490444/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
  • Loading branch information
machadoum committed Aug 3, 2023
1 parent ed7d6cf commit b6d94d7
Show file tree
Hide file tree
Showing 22 changed files with 250 additions and 22 deletions.
5 changes: 5 additions & 0 deletions x-pack/plugins/security_solution/common/types/app_features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ export const getSecurityAppFeaturesConfig = (
},
},
},
[AppFeatureSecurityKey.investigationGuide]: {
privileges: {
all: {
ui: ['investigation-guide'],
},
read: {
ui: ['investigation-guide'],
},
},
},

[AppFeatureSecurityKey.threatIntelligence]: {
privileges: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const PLI_APP_FEATURES: PliAppFeatures = {
essentials: [],
complete: [
AppFeatureKey.advancedInsights,
AppFeatureKey.investigationGuide,
AppFeatureKey.threatIntelligence,
AppFeatureKey.casesConnectors,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit b6d94d7

Please sign in to comment.