[SecuritySolutions] Update asset criticality upload page visibility and permissions (#180771)

## Summary

Related to https://github.com/elastic/kibana/pull/179891

* Display error message in case user doesn't have write access to asset
criticality index
![Screenshot 2024-04-15 at 14 34
17](9d92fe32-26cd-4c22-be0f-951f2a719c2b)

* Display error message if the user navigates to the page when the user
advanced (UI) setting is disabled
![Screenshot 2024-04-15 at 14 32
35](1ef5a079-de19-40c7-b378-30f707483e99)

* Remove links from menus and global search if advanced (UI) setting is
disabled
![Screenshot 2024-04-15 at 14 40
35](0342aeba-8a45-457e-958d-0e65bcc7cd80)


Recoding of what happens when you don't refresh the page:



2b67403c-d58e-4d92-b12d-9f9de4c9a213





### Expected behaviour:
* After the setting is enabled, the user needs to refresh the browser to
find the page
* If users disable the setting on a different browser/tab and navigate
to the page without refreshing, they will see an error message on the
page.
* If users disable the flag while the page is already rendered and try
to upload the file, it will display an error on the last step.


### 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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2024-04-16 14:03:25 +02:00 committed by GitHub
parent e60d23a2ff
commit da890be8f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 209 additions and 11 deletions

View file

@ -12,6 +12,7 @@ import { updateAppLinks } from '../../links';
import { mockGlobalState } from '../../mock';
import type { Capabilities } from '@kbn/core-capabilities-common';
import { UpsellingService } from '@kbn/security-solution-upselling/service';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
const defaultAppLinks: AppLinkItems = [
{
@ -29,6 +30,7 @@ const defaultAppLinks: AppLinkItems = [
];
const mockUpselling = new UpsellingService();
const mockUiSettingsClient = uiSettingsServiceMock.createStartContract();
describe('helpers', () => {
beforeAll(() => {
@ -36,6 +38,7 @@ describe('helpers', () => {
capabilities: {} as unknown as Capabilities,
experimentalFeatures: mockGlobalState.app.enableExperimental,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
});
it('returns the search string', () => {

View file

@ -9,7 +9,7 @@ import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../comm
import type { Capabilities } from '@kbn/core/types';
import { mockGlobalState, TestProviders } from '../mock';
import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types';
import type { AppLinkItems } from './types';
import type { AppLinkItems, LinkItem, LinksPermissions } from './types';
import { act, renderHook } from '@testing-library/react-hooks';
import {
useAppLinks,
@ -18,11 +18,13 @@ import {
needsUrlState,
updateAppLinks,
useLinkExists,
isLinkUiSettingsAllowed,
} from './links';
import { createCapabilities } from './test_utils';
import { hasCapabilities } from '../lib/capabilities';
import { UpsellingService } from '@kbn/security-solution-upselling/service';
import React from 'react';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
const defaultAppLinks: AppLinkItems = [
{
@ -79,6 +81,8 @@ const mockLicense = {
hasAtLeast: licensePremiumMock,
} as unknown as ILicense;
const mockUiSettingsClient = uiSettingsServiceMock.createStartContract();
const renderUseAppLinks = () =>
renderHook<{}, AppLinkItems>(() => useAppLinks(), { wrapper: TestProviders });
const renderUseLinkExists = (id: SecurityPageName) =>
@ -95,6 +99,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
});
@ -174,6 +179,7 @@ describe('Security links', () => {
} as unknown as typeof mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
@ -240,6 +246,7 @@ describe('Security links', () => {
} as unknown as typeof mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling,
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
@ -269,6 +276,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
await waitForNextUpdate();
});
@ -300,6 +308,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
await waitForNextUpdate();
});
@ -338,6 +347,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: new UpsellingService(),
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
@ -369,6 +379,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
@ -532,4 +543,74 @@ describe('Security links', () => {
).toBeFalsy();
});
});
describe('isLinkUiSettingsAllowed', () => {
const SETTING_KEY = 'test setting';
const mockedLink: LinkItem = {
id: SecurityPageName.entityAnalyticsAssetClassification,
title: 'test title',
path: '/test_path',
};
const mockedPermissions = {
uiSettingsClient: mockUiSettingsClient,
} as unknown as LinksPermissions;
afterEach(() => {
jest.clearAllMocks();
});
it('returns true when uiSettingRequired is not set', () => {
const link: LinkItem = {
...mockedLink,
uiSettingRequired: undefined,
};
expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeTruthy();
expect(mockUiSettingsClient.get).not.toHaveBeenCalled();
});
it('returns true when uiSettingRequired is a string and the corresponding UI setting is true', () => {
mockUiSettingsClient.get = jest.fn().mockReturnValue(true);
const link: LinkItem = {
...mockedLink,
uiSettingRequired: SETTING_KEY,
};
expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeTruthy();
expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY);
});
it('returns false when uiSettingRequired is a string and the corresponding UI setting is false', () => {
mockUiSettingsClient.get = jest.fn().mockReturnValue(false);
const link: LinkItem = {
...mockedLink,
uiSettingRequired: SETTING_KEY,
};
expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeFalsy();
expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY);
});
it('returns true when uiSettingRequired is an object and the corresponding UI setting matches the value', () => {
const link: LinkItem = {
...mockedLink,
uiSettingRequired: { key: SETTING_KEY, value: 'any text' },
};
mockUiSettingsClient.get = jest.fn().mockReturnValue('any text');
expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeTruthy();
expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY);
});
it('returns false when uiSettingRequired is an object and the corresponding UI setting does not match the value', () => {
const link: LinkItem = {
...mockedLink,
uiSettingRequired: { key: SETTING_KEY, value: 'any text' },
};
mockUiSettingsClient.get = jest.fn().mockReturnValue('different text');
expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeFalsy();
expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY);
});
});
});

View file

@ -153,7 +153,10 @@ const getNormalizedLink = (id: SecurityPageName): Readonly<NormalizedLink> | und
const processAppLinks = (appLinks: AppLinkItems, linksPermissions: LinksPermissions): LinkItem[] =>
appLinks.reduce<LinkItem[]>((acc, { links, ...appLinkWithoutSublinks }) => {
if (!isLinkExperimentalKeyAllowed(appLinkWithoutSublinks, linksPermissions)) {
if (
!isLinkExperimentalKeyAllowed(appLinkWithoutSublinks, linksPermissions) ||
!isLinkUiSettingsAllowed(appLinkWithoutSublinks, linksPermissions)
) {
return acc;
}
@ -179,6 +182,23 @@ const processAppLinks = (appLinks: AppLinkItems, linksPermissions: LinksPermissi
return acc;
}, []);
export const isLinkUiSettingsAllowed = (link: LinkItem, { uiSettingsClient }: LinksPermissions) => {
if (!link.uiSettingRequired) {
return true;
}
if (typeof link.uiSettingRequired === 'string') {
return uiSettingsClient.get(link.uiSettingRequired) === true;
}
if (typeof link.uiSettingRequired === 'object') {
return uiSettingsClient.get(link.uiSettingRequired.key) === link.uiSettingRequired.value;
}
// unsupported uiSettingRequired type
return false;
};
const isLinkExperimentalKeyAllowed = (
link: LinkItem,
{ experimentalFeatures }: LinksPermissions

View file

@ -19,6 +19,7 @@ import type { UpsellingService } from '@kbn/security-solution-upselling/service'
import type { AppDeepLinkLocations } from '@kbn/core-application-browser';
import type { Observable } from 'rxjs';
import type { SolutionSideNavItem as ClassicSolutionSideNavItem } from '@kbn/security-solution-side-nav';
import type { IUiSettingsClient } from '@kbn/core/public';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import type { RequiredCapabilities } from '../lib/capabilities';
@ -37,6 +38,7 @@ export type SolutionSideNavItem = ClassicSolutionSideNavItem<SolutionPageName>;
export interface LinksPermissions {
capabilities: Capabilities;
experimentalFeatures: Readonly<ExperimentalFeatures>;
uiSettingsClient: IUiSettingsClient;
upselling: UpsellingService;
license?: ILicense;
}
@ -154,6 +156,13 @@ export interface LinkItem {
* Locations where the link is visible in the UI
*/
visibleIn?: AppDeepLinkLocations[];
/**
* Required UI setting to enable a link.
* To enable a link when a boolean UiSetting is true, pass the key as a string.
* To enable a link when a specific value is set for a UiSetting, pass an object with key and value.
*/
uiSettingRequired?: string | { key: string; value: unknown };
}
export type AppLinkItems = Readonly<LinkItem[]>;

View file

@ -11,6 +11,7 @@ import { UpsellingService } from '@kbn/security-solution-upselling/service';
import { updateAppLinks } from '../../links';
import { appLinks } from '../../../app_links';
import { useShowTimeline } from './use_show_timeline';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' });
jest.mock('react-router-dom', () => {
@ -53,6 +54,7 @@ jest.mock('../../lib/kibana', () => {
});
const mockUpselling = new UpsellingService();
const mockUiSettingsClient = uiSettingsServiceMock.createStartContract();
describe('use show timeline', () => {
beforeAll(() => {
@ -70,6 +72,7 @@ describe('use show timeline', () => {
},
},
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
});

View file

@ -8,6 +8,7 @@
import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import type { SecurityAppError } from '@kbn/securitysolution-t-grid';
import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types';
import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../common/constants';
import { useHasSecurityCapability } from '../../../helper_hooks';
@ -21,21 +22,23 @@ const PRIVILEGES_KEY = 'PRIVILEGES';
const nonAuthorizedResponse: Promise<EntityAnalyticsPrivileges> = Promise.resolve({
has_all_required: false,
has_write_permissions: false,
has_read_permissions: false,
privileges: {
elasticsearch: {},
},
});
export const useAssetCriticalityPrivileges = (
entityName: string
): UseQueryResult<EntityAnalyticsPrivileges> => {
queryKey: string
): UseQueryResult<EntityAnalyticsPrivileges, SecurityAppError> => {
const { fetchAssetCriticalityPrivileges } = useEntityAnalyticsRoutes();
const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics');
const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING);
const isEnabled = isAssetCriticalityEnabled && hasEntityAnalyticsCapability;
return useQuery({
queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, entityName, isEnabled],
queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, queryKey, isEnabled],
queryFn: isEnabled ? fetchAssetCriticalityPrivileges : () => nonAuthorizedResponse,
});
};

View file

@ -16,16 +16,92 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
EuiEmptyPrompt,
EuiCallOut,
EuiCode,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { ASSET_CRITICALITY_INDEX_PATTERN } from '../../../common/entity_analytics/asset_criticality';
import { useUiSetting$, useKibana } from '../../common/lib/kibana';
import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../common/constants';
import { AssetCriticalityFileUploader } from '../components/asset_criticality_file_uploader/asset_criticality_file_uploader';
import { useKibana } from '../../common/lib/kibana';
import { useAssetCriticalityPrivileges } from '../components/asset_criticality/use_asset_criticality';
import { useHasSecurityCapability } from '../../helper_hooks';
export const AssetCriticalityUploadPage = () => {
const { docLinks } = useKibana().services;
const entityAnalyticsLinks = docLinks.links.securitySolution.entityAnalytics;
const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics');
const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING);
const {
data: privileges,
error: privilegesError,
isLoading,
} = useAssetCriticalityPrivileges('AssetCriticalityUploadPage');
const hasWritePermissions = privileges?.has_write_permissions;
if (isLoading) {
// Wait for permission before rendering content to avoid flickering
return null;
}
if (
!hasEntityAnalyticsCapability ||
!isAssetCriticalityEnabled ||
privilegesError?.body.status_code === 403
) {
const errorMessage = privilegesError?.body.message ?? (
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.advancedSettingDisabledMessage"
defaultMessage='Please enable "{ENABLE_ASSET_CRITICALITY_SETTING}" on advanced settings to access the page.'
values={{
ENABLE_ASSET_CRITICALITY_SETTING,
}}
/>
);
return (
<EuiEmptyPrompt
iconType="warning"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.advancedSettingDisabledTitle"
defaultMessage="This page is disabled"
/>
</h2>
}
body={<p>{errorMessage}</p>}
/>
);
}
if (!hasWritePermissions) {
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.noPermissionTitle"
defaultMessage="Insufficient index privileges to access this page"
/>
}
color="primary"
iconType="iInCircle"
>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.missingPermissionsCallout.description"
defaultMessage="Write permission is required for the {index} index pattern in order to access this page. Contact your administrator for further assistance."
values={{
index: <EuiCode>{ASSET_CRITICALITY_INDEX_PATTERN}</EuiCode>,
}}
/>
</EuiText>
</EuiCallOut>
);
}
return (
<>
<EuiPageHeader
@ -62,7 +138,7 @@ export const AssetCriticalityUploadPage = () => {
<EuiFlexItem grow={2}>
<EuiPanel hasBorder={true} paddingSize="l" grow={false}>
<EuiIcon type={'questionInCircle'} size={'xl'} />
<EuiIcon type="questionInCircle" size="xl" />
<EuiSpacer size="m" />
<EuiTitle size="xxs">
<h3>

View file

@ -101,7 +101,7 @@ export const EA_DOCS_ENTITY_RISK_SCORE = i18n.translate(
export const PREVIEW_MISSING_PERMISSIONS_TITLE = i18n.translate(
'xpack.securitySolution.riskScore.riskScorePreview.missingPermissionsCallout.title',
{
defaultMessage: 'Insifficient index privileges to preview data',
defaultMessage: 'Insufficient index privileges to preview data',
}
);

View file

@ -15,6 +15,7 @@ import {
} from '../../common/endpoint/service/authz';
import {
BLOCKLIST_PATH,
ENABLE_ASSET_CRITICALITY_SETTING,
ENDPOINTS_PATH,
ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH,
ENTITY_ANALYTICS_MANAGEMENT_PATH,
@ -200,7 +201,7 @@ export const links: LinkItem = {
skipUrlState: true,
hideTimeline: true,
capabilities: [`${SERVER_APP_ID}.entity-analytics`],
licenseType: 'platinum',
uiSettingRequired: ENABLE_ASSET_CRITICALITY_SETTING,
},
{
id: SecurityPageName.responseActionsHistory,

View file

@ -383,6 +383,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
experimentalFeatures: this.experimentalFeatures,
upselling: upsellingService,
capabilities: core.application.capabilities,
uiSettingsClient: core.uiSettings,
...(license.type != null && { license }),
};
updateAppLinks(links, linksPermissions);

View file

@ -196,6 +196,7 @@
"@kbn/core-http-server-mocks",
"@kbn/data-service",
"@kbn/core-chrome-browser",
"@kbn/shared-ux-chrome-navigation"
"@kbn/shared-ux-chrome-navigation",
"@kbn/core-ui-settings-browser-mocks"
]
}