Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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) =>
Expand All @@ -95,6 +99,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
});

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -240,6 +246,7 @@ describe('Security links', () => {
} as unknown as typeof mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling,
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
Expand Down Expand Up @@ -269,6 +276,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
await waitForNextUpdate();
});
Expand Down Expand Up @@ -300,6 +308,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: { hasAtLeast: licenseBasicMock } as unknown as ILicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
});
await waitForNextUpdate();
});
Expand Down Expand Up @@ -338,6 +347,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: new UpsellingService(),
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
Expand Down Expand Up @@ -369,6 +379,7 @@ describe('Security links', () => {
experimentalFeatures: mockExperimentalDefaults,
license: mockLicense,
upselling: mockUpselling,
uiSettingsClient: mockUiSettingsClient,
}
);
await waitForNextUpdate();
Expand Down Expand Up @@ -532,4 +543,73 @@ 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();
machadoum marked this conversation as resolved.
Show resolved Hide resolved
});

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);
});
});
});
19 changes: 18 additions & 1 deletion x-pack/plugins/security_solution/public/common/links/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -179,6 +182,20 @@ const processAppLinks = (appLinks: AppLinkItems, linksPermissions: LinksPermissi
return acc;
}, []);

export const isLinkUiSettingsAllowed = (link: LinkItem, { uiSettingsClient }: LinksPermissions) => {
if (link.uiSettingRequired) {
machadoum marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}

return true;
};

const isLinkExperimentalKeyAllowed = (
link: LinkItem,
{ experimentalFeatures }: LinksPermissions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -37,6 +38,7 @@ export type SolutionSideNavItem = ClassicSolutionSideNavItem<SolutionPageName>;
export interface LinksPermissions {
capabilities: Capabilities;
experimentalFeatures: Readonly<ExperimentalFeatures>;
uiSettingsClient: IUiSettingsClient;
upselling: UpsellingService;
license?: ILicense;
}
Expand Down Expand Up @@ -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: string | number | boolean };
machadoum marked this conversation as resolved.
Show resolved Hide resolved
}

export type AppLinkItems = Readonly<LinkItem[]>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,94 @@ 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="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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,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);
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/security_solution/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@
"@kbn/core-http-server-mocks",
"@kbn/data-service",
"@kbn/core-chrome-browser",
"@kbn/shared-ux-chrome-navigation"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auto-generated 8e1f50e

"@kbn/shared-ux-chrome-navigation",
"@kbn/core-ui-settings-browser-mocks"
]
}