Skip to content

Commit 0c6b7c5

Browse files
committed
Hide GraphPreview if user license is lower than required
1 parent 831158b commit 0c6b7c5

File tree

9 files changed

+255
-3
lines changed

9 files changed

+255
-3
lines changed

x-pack/solutions/security/packages/features/src/product_features_keys.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ export enum ProductFeatureSecurityKey {
117117
* Enables customization of prebuilt Elastic rules
118118
*/
119119
prebuiltRuleCustomization = 'prebuilt_rule_customization',
120+
121+
/**
122+
* Enables graph visualization for alerts and events
123+
*/
124+
graphVisualization = 'graph_visualization',
120125
}
121126

122127
export enum ProductFeatureCasesKey {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { renderHook } from '@testing-library/react';
9+
import { useHasGraphVisualizationAccess } from './use_has_graph_visualization_access';
10+
import { useKibana } from '../lib/kibana';
11+
import { useLicense } from './use_license';
12+
import { BehaviorSubject } from 'rxjs';
13+
14+
jest.mock('../lib/kibana');
15+
jest.mock('./use_license');
16+
17+
describe('useHasGraphVisualizationAccess', () => {
18+
const mockUseKibana = useKibana as jest.Mock;
19+
const mockUseLicense = useLicense as jest.Mock;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
describe('ESS/Self-Managed Environment', () => {
26+
it('should return true when user has Enterprise license', () => {
27+
const productFeatureKeys$ = new BehaviorSubject<Set<string> | null>(null);
28+
29+
mockUseKibana.mockReturnValue({
30+
services: {
31+
productFeatureKeys$,
32+
serverless: undefined,
33+
},
34+
});
35+
36+
mockUseLicense.mockReturnValue({
37+
isEnterprise: jest.fn(() => true),
38+
});
39+
40+
const { result } = renderHook(() => useHasGraphVisualizationAccess());
41+
42+
expect(result.current).toBe(true);
43+
});
44+
45+
it('should return false when user does not have Enterprise license', () => {
46+
const productFeatureKeys$ = new BehaviorSubject<Set<string> | null>(null);
47+
48+
mockUseKibana.mockReturnValue({
49+
services: {
50+
productFeatureKeys$,
51+
serverless: undefined,
52+
},
53+
});
54+
55+
mockUseLicense.mockReturnValue({
56+
isEnterprise: jest.fn(() => false),
57+
});
58+
59+
const { result } = renderHook(() => useHasGraphVisualizationAccess());
60+
61+
expect(result.current).toBe(false);
62+
});
63+
});
64+
65+
describe('Serverless Environment', () => {
66+
it('should return true when user has Complete tier with graphVisualization feature', () => {
67+
const productFeatureKeys$ = new BehaviorSubject<Set<string> | null>(
68+
new Set<string>(['graph_visualization'])
69+
);
70+
71+
mockUseKibana.mockReturnValue({
72+
services: {
73+
productFeatureKeys$,
74+
serverless: {
75+
projectType: 'security',
76+
},
77+
},
78+
});
79+
80+
mockUseLicense.mockReturnValue({
81+
isEnterprise: jest.fn(() => false),
82+
});
83+
84+
const { result } = renderHook(() => useHasGraphVisualizationAccess());
85+
86+
expect(result.current).toBe(true);
87+
});
88+
89+
it('should return false when user has Essentials tier without graphVisualization feature', () => {
90+
const productFeatureKeys$ = new BehaviorSubject<Set<string> | null>(new Set<string>([]));
91+
92+
mockUseKibana.mockReturnValue({
93+
services: {
94+
productFeatureKeys$,
95+
serverless: {
96+
projectType: 'security',
97+
},
98+
},
99+
});
100+
101+
mockUseLicense.mockReturnValue({
102+
isEnterprise: jest.fn(() => false),
103+
});
104+
105+
const { result } = renderHook(() => useHasGraphVisualizationAccess());
106+
107+
expect(result.current).toBe(false);
108+
});
109+
110+
it('should return false when productFeatureKeys is null', () => {
111+
const productFeatureKeys$ = new BehaviorSubject<Set<string> | null>(null);
112+
113+
mockUseKibana.mockReturnValue({
114+
services: {
115+
productFeatureKeys$,
116+
serverless: {
117+
projectType: 'security',
118+
},
119+
},
120+
});
121+
122+
mockUseLicense.mockReturnValue({
123+
isEnterprise: jest.fn(() => true),
124+
});
125+
126+
const { result } = renderHook(() => useHasGraphVisualizationAccess());
127+
128+
expect(result.current).toBe(false);
129+
});
130+
131+
it('should prioritize PLI check over license check in serverless mode', () => {
132+
const productFeatureKeys$ = new BehaviorSubject<Set<string> | null>(new Set<string>([]));
133+
134+
mockUseKibana.mockReturnValue({
135+
services: {
136+
productFeatureKeys$,
137+
serverless: {
138+
projectType: 'security',
139+
},
140+
},
141+
});
142+
143+
// Even though isEnterprise returns true, PLI check should take precedence
144+
const isEnterpriseMock = jest.fn(() => true);
145+
mockUseLicense.mockReturnValue({
146+
isEnterprise: isEnterpriseMock,
147+
});
148+
149+
const { result } = renderHook(() => useHasGraphVisualizationAccess());
150+
151+
// Should return false because graphVisualization is not in PLI
152+
expect(result.current).toBe(false);
153+
154+
// isEnterprise should not be called in serverless mode
155+
expect(isEnterpriseMock).not.toHaveBeenCalled();
156+
});
157+
});
158+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import useObservable from 'react-use/lib/useObservable';
9+
import { ProductFeatureSecurityKey } from '@kbn/security-solution-features/keys';
10+
import { useKibana } from '../lib/kibana';
11+
import { useLicense } from './use_license';
12+
13+
/**
14+
* Hook to determine if the user has the required license for graph visualization feature.
15+
*
16+
* In ESS/Self-Managed: Requires Enterprise license or higher
17+
* In Serverless: Requires Security Analytics Complete tier (not Essentials)
18+
*
19+
* @returns boolean indicating if graph visualization is available
20+
*/
21+
export const useHasGraphVisualizationAccess = (): boolean => {
22+
const { productFeatureKeys$, serverless } = useKibana().services;
23+
const licenseService = useLicense();
24+
25+
// Get current product feature keys from observable (serverless PLI system)
26+
const productFeatureKeys = useObservable(productFeatureKeys$, null);
27+
28+
// Detect if running in serverless mode
29+
const isServerless = serverless !== undefined;
30+
31+
if (isServerless) {
32+
// In serverless: Check if Complete tier feature is enabled
33+
return productFeatureKeys?.has(ProductFeatureSecurityKey.graphVisualization) ?? false;
34+
} else {
35+
// In ESS/Self-Managed: Check for Enterprise license or higher
36+
return licenseService.isEnterprise();
37+
}
38+
};

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { useGraphPreview } from './use_graph_preview';
1212
import type { GetFieldsData } from './use_get_fields_data';
1313
import { mockFieldData } from '../mocks/mock_get_fields_data';
1414
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_data_formatted_for_field_browser';
15+
import { useHasGraphVisualizationAccess } from '../../../../common/hooks/use_has_graph_visualization_access';
16+
17+
jest.mock('../../../../common/hooks/use_has_graph_visualization_access');
18+
const mockUseHasGraphVisualizationAccess = useHasGraphVisualizationAccess as jest.Mock;
1519

1620
const alertMockGetFieldsData: GetFieldsData = (field: string) => {
1721
if (field === 'kibana.alert.uuid') {
@@ -48,6 +52,11 @@ const eventMockGetFieldsData: GetFieldsData = (field: string) => {
4852
const eventMockDataFormattedForFieldBrowser: TimelineEventsDetailsItem[] = [];
4953

5054
describe('useGraphPreview', () => {
55+
beforeEach(() => {
56+
jest.clearAllMocks();
57+
// Default mock: graph visualization feature is available
58+
mockUseHasGraphVisualizationAccess.mockReturnValue(true);
59+
});
5160
it(`should return false when missing actor`, () => {
5261
const getFieldsData: GetFieldsData = (field: string) => {
5362
if (field === 'actor.entity.id') {
@@ -326,4 +335,34 @@ describe('useGraphPreview', () => {
326335
isAlert: true,
327336
});
328337
});
338+
339+
describe('License checking', () => {
340+
it('should return false when all conditions are met but env does not have required license', () => {
341+
mockUseHasGraphVisualizationAccess.mockReturnValue(false);
342+
343+
const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), {
344+
initialProps: {
345+
getFieldsData: alertMockGetFieldsData,
346+
ecsData: {
347+
_id: 'id',
348+
event: {
349+
action: ['action'],
350+
},
351+
},
352+
dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser,
353+
},
354+
});
355+
356+
expect(hookResult.result.current.hasGraphRepresentation).toBe(false);
357+
expect(hookResult.result.current).toStrictEqual({
358+
hasGraphRepresentation: false,
359+
timestamp: mockFieldData['@timestamp'][0],
360+
eventIds: ['eventId'],
361+
actorIds: ['actorId'],
362+
action: ['action'],
363+
targetIds: ['targetId'],
364+
isAlert: true,
365+
});
366+
});
367+
});
329368
});

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { get } from 'lodash/fp';
1111
import type { GetFieldsData } from './use_get_fields_data';
1212
import { getField, getFieldArray } from '../utils';
1313
import { useBasicDataFromDetailsData } from './use_basic_data_from_details_data';
14+
import { useHasGraphVisualizationAccess } from '../../../../common/hooks/use_has_graph_visualization_access';
1415

1516
export interface UseGraphPreviewParams {
1617
/**
@@ -58,7 +59,7 @@ export interface UseGraphPreviewResult {
5859
action?: string[];
5960

6061
/**
61-
* Boolean indicating if the event is has a graph representation (contains event ids, actor ids and action)
62+
* Boolean indicating if the event has a graph representation (contains event ids, actor ids, action, and valid license)
6263
*/
6364
hasGraphRepresentation: boolean;
6465

@@ -84,12 +85,18 @@ export const useGraphPreview = ({
8485
const actorIds = getFieldArray(getFieldsData('actor.entity.id'));
8586
const targetIds = getFieldArray(getFieldsData('target.entity.id'));
8687
const action: string[] | undefined = get(['event', 'action'], ecsData);
88+
89+
// Check if user license is high enough to access graph visualization
90+
const hasRequiredLicense = useHasGraphVisualizationAccess();
91+
8792
const hasGraphRepresentation =
8893
Boolean(timestamp) &&
8994
Boolean(action?.length) &&
9095
actorIds.length > 0 &&
9196
eventIds.length > 0 &&
92-
targetIds.length > 0;
97+
targetIds.length > 0 &&
98+
hasRequiredLicense;
99+
93100
const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
94101

95102
return { timestamp, eventIds, actorIds, action, targetIds, hasGraphRepresentation, isAlert };

x-pack/solutions/security/plugins/security_solution/public/mocks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { BehaviorSubject, of } from 'rxjs';
99
import { UpsellingService } from '@kbn/security-solution-upselling/service';
10+
import type { ProductFeatureKeyType } from '@kbn/security-solution-features';
1011
import type { BreadcrumbsNav } from './common/breadcrumbs';
1112
import { allowedExperimentalValues } from '../common/experimental_features';
1213
import type { PluginStart, PluginSetup, ContractStartServices } from './types';
@@ -19,6 +20,7 @@ export const contractStartServicesMock: ContractStartServices = {
1920
getComponents$: jest.fn(() => of({})),
2021
upselling,
2122
onboarding: onboardingService,
23+
productFeatureKeys$: new BehaviorSubject<Set<ProductFeatureKeyType> | null>(null),
2224
};
2325

2426
const setupMock = (): PluginSetup => ({

x-pack/solutions/security/plugins/security_solution/public/plugin_contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class PluginContract {
6262
getComponents$: this.componentsService.getComponents$.bind(this.componentsService),
6363
upselling: this.upsellingService,
6464
onboarding: this.onboardingService,
65+
productFeatureKeys$: this.productFeatureKeys$,
6566
};
6667
}
6768
}

x-pack/solutions/security/plugins/security_solution/public/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import type { MapsStartApi } from '@kbn/maps-plugin/public';
6161
import type { ServerlessPluginStart } from '@kbn/serverless/public';
6262
import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public';
6363
import type { AutomaticImportPluginStart } from '@kbn/automatic-import-plugin/public';
64-
import type { ProductFeatureKeys } from '@kbn/security-solution-features';
64+
import type { ProductFeatureKeyType, ProductFeatureKeys } from '@kbn/security-solution-features';
6565
import type { ElasticAssistantSharedStatePublicPluginStart } from '@kbn/elastic-assistant-shared-state-plugin/public';
6666
import type { InferencePublicStart } from '@kbn/inference-plugin/public';
6767
import type { ResolverPluginSetup } from './resolver/types';
@@ -172,6 +172,7 @@ export interface ContractStartServices {
172172
getComponents$: GetComponents$;
173173
upselling: UpsellingService;
174174
onboarding: OnboardingService;
175+
productFeatureKeys$: Observable<Set<ProductFeatureKeyType> | null>;
175176
}
176177

177178
export type StartServices = CoreStart &

x-pack/solutions/security/plugins/security_solution_serverless/common/pli/pli_config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const PLI_PRODUCT_FEATURES: PliProductFeatures = {
5858
ProductFeatureKey.prebuiltRuleCustomization,
5959
ProductFeatureKey.siemMigrations,
6060
ProductFeatureKey.aiValueReport,
61+
ProductFeatureKey.graphVisualization,
6162
],
6263
},
6364
[ProductLine.endpoint]: {

0 commit comments

Comments
 (0)