From 0e3b83b595906b42fc386e19451759399ee3e74e Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Wed, 6 Nov 2024 11:23:30 +0100 Subject: [PATCH] [SecuritySolution] Check user permissions before initialising entity engine (#198661) ## Summary * Create privileges API for the Entity Store * Create missing privileges callout * Add missing Entity Store privileges callout to Entity Store * Add missing Entity Store privileges callout to Dashboard ![Screenshot 2024-11-04 at 15 57 15](https://github.com/user-attachments/assets/ed013571-4f0d-4605-bd2a-faa5ad3ac3e6) ![Screenshot 2024-11-04 at 16 16 03](https://github.com/user-attachments/assets/4cf6cf7d-a8c1-4c96-8fd1-2bf8be9f785e) https://github.com/user-attachments/assets/30cdb096-24cd-4a1c-a20b-abbbece865d7 ### Update: I added a "Line clamp" and "Read More" button as requested by Mark: ![Screenshot 2024-11-05 at 13 15 51](https://github.com/user-attachments/assets/42fbec93-e258-49af-8acc-ae18314be442) ### Checklist - [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 - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) --- .../api/entity_analytics/common/common.gen.ts | 18 +-- .../common/common.schema.yaml | 18 ++- .../entity_store/engine/get_privileges.gen.ts | 22 ++++ .../engine/get_privileges.schema.yaml | 19 +++ .../common/api/quickstart_client.gen.ts | 13 ++ .../entity_store/constants.ts | 12 +- .../entity_analytics/privileges.test.ts | 101 ++++++++++++++++ .../common/entity_analytics/privileges.ts | 30 +++++ .../common/components/line_clamp/index.tsx | 29 +++-- .../public/entity_analytics/api/api.ts | 15 ++- .../components/enablement_modal.tsx | 12 +- .../components/missing_privileges_callout.tsx | 114 ++++++++++++++++++ .../hooks/use_entity_engine_privileges.ts | 22 ++++ .../pages/entity_store_management_page.tsx | 18 ++- .../components/investigation_guide_view.tsx | 2 +- .../routes/__mocks__/request_context.ts | 1 + .../entity_store/routes/privileges.ts | 76 ++++++++++++ .../routes/register_entity_store_routes.ts | 2 + .../utils/get_entity_store_privileges.ts | 51 ++++++++ .../routes/risk_engine_privileges.mock.ts | 2 + .../utils/check_and_format_privileges.test.ts | 3 + .../utils/check_and_format_privileges.ts | 6 +- .../server/request_context_factory.ts | 2 + .../plugins/security_solution/server/types.ts | 2 + .../services/security_solution_api.gen.ts | 7 ++ .../asset_criticality_privileges.ts | 5 + .../risk_engine_privileges.ts | 5 + 27 files changed, 565 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts create mode 100644 x-pack/plugins/security_solution/common/entity_analytics/privileges.ts create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts index fcf8f5e3c6a71..267cf4d59a956 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts @@ -25,22 +25,10 @@ export const EntityAnalyticsPrivileges = z.object({ has_write_permissions: z.boolean().optional(), privileges: z.object({ elasticsearch: z.object({ - cluster: z - .object({ - manage_index_templates: z.boolean().optional(), - manage_transform: z.boolean().optional(), - }) - .optional(), - index: z - .object({}) - .catchall( - z.object({ - read: z.boolean().optional(), - write: z.boolean().optional(), - }) - ) - .optional(), + cluster: z.object({}).catchall(z.boolean()).optional(), + index: z.object({}).catchall(z.object({}).catchall(z.boolean())).optional(), }), + kibana: z.object({}).catchall(z.boolean()).optional(), }), }); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml index 67428b261a0f9..1da4eca994aed 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml @@ -23,20 +23,18 @@ components: properties: cluster: type: object - properties: - manage_index_templates: - type: boolean - manage_transform: - type: boolean + additionalProperties: + type: boolean index: type: object additionalProperties: type: object - properties: - read: - type: boolean - write: - type: boolean + additionalProperties: + type: boolean + kibana: + type: object + additionalProperties: + type: boolean required: - elasticsearch required: diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts new file mode 100644 index 0000000000000..a9cbc9d75e00c --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get Entity Store Privileges Schema + * version: 1 + */ + +import type { z } from '@kbn/zod'; + +import { EntityAnalyticsPrivileges } from '../../common/common.gen'; + +export type EntityStoreGetPrivilegesResponse = z.infer; +export const EntityStoreGetPrivilegesResponse = EntityAnalyticsPrivileges; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml new file mode 100644 index 0000000000000..f1db3b3f93a5a --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.0 +info: + title: Get Entity Store Privileges Schema + version: '1' +paths: + /internal/entity_store/privileges: + get: + x-labels: [ess, serverless] + x-internal: true + x-codegen-enabled: true + operationId: EntityStoreGetPrivileges + summary: Get Entity Store Privileges + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../common/common.schema.yaml#/components/schemas/EntityAnalyticsPrivileges' diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 25560aeffdbbe..e4f4faaa7a2d5 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -249,6 +249,7 @@ import type { DeleteEntityEngineRequestParamsInput, DeleteEntityEngineResponse, } from './entity_analytics/entity_store/engine/delete.gen'; +import type { EntityStoreGetPrivilegesResponse } from './entity_analytics/entity_store/engine/get_privileges.gen'; import type { GetEntityEngineRequestParamsInput, GetEntityEngineResponse, @@ -1119,6 +1120,18 @@ If a record already exists for the specified entity, that record is overwritten }) .catch(catchAxiosErrorFormatAndThrow); } + async entityStoreGetPrivileges() { + this.log.info(`${new Date().toISOString()} Calling API EntityStoreGetPrivileges`); + return this.kbnClient + .request({ + path: '/internal/entity_store/privileges', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Export detection rules to an `.ndjson` file. The following configuration items are also included in the `.ndjson` file: - Actions diff --git a/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts b/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts index 157ec6845e33a..b6834422c8cfc 100644 --- a/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts +++ b/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts @@ -10,6 +10,16 @@ */ export const ENTITY_STORE_URL = '/api/entity_store' as const; +export const ENTITY_STORE_INTERNAL_PRIVILEGES_URL = `${ENTITY_STORE_URL}/privileges` as const; export const ENTITIES_URL = `${ENTITY_STORE_URL}/entities` as const; - export const LIST_ENTITIES_URL = `${ENTITIES_URL}/list` as const; + +export const ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES = [ + 'manage_index_templates', + 'manage_transform', + 'manage_ingest_pipelines', + 'manage_enrich', +]; + +// The index pattern for the entity store has to support '.entities.v1.latest.noop' index +export const ENTITY_STORE_INDEX_PATTERN = '.entities.v1.latest.*'; diff --git a/x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts b/x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts new file mode 100644 index 0000000000000..60947192a892f --- /dev/null +++ b/x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { getAllMissingPrivileges } from './privileges'; +import type { EntityAnalyticsPrivileges } from '../api/entity_analytics'; + +describe('getAllMissingPrivileges', () => { + it('should return all missing privileges for elasticsearch and kibana', () => { + const privileges: EntityAnalyticsPrivileges = { + privileges: { + elasticsearch: { + index: { + 'logs-*': { read: true, view_index_metadata: true }, + 'auditbeat-*': { read: false, view_index_metadata: false }, + }, + cluster: { + manage_enrich: false, + manage_ingest_pipelines: true, + }, + }, + kibana: { + 'saved_object:entity-engine-status/all': false, + 'saved_object:entity-definition/all': true, + }, + }, + has_all_required: false, + has_read_permissions: false, + has_write_permissions: false, + }; + + const result = getAllMissingPrivileges(privileges); + + expect(result).toEqual({ + elasticsearch: { + index: [{ indexName: 'auditbeat-*', privileges: ['read', 'view_index_metadata'] }], + cluster: ['manage_enrich'], + }, + kibana: ['saved_object:entity-engine-status/all'], + }); + }); + + it('should return empty lists if all privileges are true', () => { + const privileges: EntityAnalyticsPrivileges = { + privileges: { + elasticsearch: { + index: { + 'logs-*': { read: true, view_index_metadata: true }, + }, + cluster: { + manage_enrich: true, + }, + }, + kibana: { + 'saved_object:entity-engine-status/all': true, + }, + }, + has_all_required: true, + has_read_permissions: true, + has_write_permissions: true, + }; + + const result = getAllMissingPrivileges(privileges); + + expect(result).toEqual({ + elasticsearch: { + index: [], + cluster: [], + }, + kibana: [], + }); + }); + + it('should handle empty privileges object', () => { + const privileges: EntityAnalyticsPrivileges = { + privileges: { + elasticsearch: { + index: {}, + cluster: {}, + }, + kibana: {}, + }, + has_all_required: false, + has_read_permissions: false, + has_write_permissions: false, + }; + + const result = getAllMissingPrivileges(privileges); + + expect(result).toEqual({ + elasticsearch: { + index: [], + cluster: [], + }, + kibana: [], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/entity_analytics/privileges.ts b/x-pack/plugins/security_solution/common/entity_analytics/privileges.ts new file mode 100644 index 0000000000000..89f90651943a4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/entity_analytics/privileges.ts @@ -0,0 +1,30 @@ +/* + * 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 { EntityAnalyticsPrivileges } from '../api/entity_analytics'; + +export const getAllMissingPrivileges = (privilege: EntityAnalyticsPrivileges) => { + const esPrivileges = privilege.privileges.elasticsearch; + const kbnPrivileges = privilege.privileges.kibana; + + const index = Object.entries(esPrivileges.index ?? {}) + .map(([indexName, indexPrivileges]) => ({ + indexName, + privileges: filterUnauthorized(indexPrivileges), + })) + .filter(({ privileges }) => privileges.length > 0); + + return { + elasticsearch: { index, cluster: filterUnauthorized(esPrivileges.cluster) }, + kibana: filterUnauthorized(kbnPrivileges), + }; +}; + +const filterUnauthorized = (obj: Record | undefined) => + Object.entries(obj ?? {}) + .filter(([_, authorized]) => !authorized) + .map(([privileges, _]) => privileges); diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx index 69e26715206e6..f92fd495dd357 100644 --- a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx @@ -13,7 +13,8 @@ import { useIsOverflow } from '../../hooks/use_is_overflow'; import * as i18n from './translations'; const LINE_CLAMP = 3; -const LINE_CLAMP_HEIGHT = 5.5; +const LINE_CLAMP_HEIGHT = '5.5em'; +const MAX_HEIGHT = '33vh'; const ReadMore = styled(EuiButtonEmpty)` span.euiButtonContent { @@ -21,26 +22,33 @@ const ReadMore = styled(EuiButtonEmpty)` } `; -const ExpandedContent = styled.div` - max-height: 33vh; +const ExpandedContent = styled.div<{ maxHeight: string }>` + max-height: ${({ maxHeight }) => maxHeight}; overflow-wrap: break-word; overflow-x: hidden; overflow-y: auto; `; -const StyledLineClamp = styled.div<{ lineClampHeight: number }>` +const StyledLineClamp = styled.div<{ lineClampHeight: string; lineClamp: number }>` display: -webkit-box; - -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-line-clamp: ${({ lineClamp }) => lineClamp}; -webkit-box-orient: vertical; overflow: hidden; - max-height: ${({ lineClampHeight }) => lineClampHeight}em; - height: ${({ lineClampHeight }) => lineClampHeight}em; + max-height: ${({ lineClampHeight }) => lineClampHeight}; + height: ${({ lineClampHeight }) => lineClampHeight}; `; const LineClampComponent: React.FC<{ children: ReactNode; - lineClampHeight?: number; -}> = ({ children, lineClampHeight = LINE_CLAMP_HEIGHT }) => { + lineClampHeight?: string; + lineClamp?: number; + maxHeight?: string; +}> = ({ + children, + lineClampHeight = LINE_CLAMP_HEIGHT, + lineClamp = LINE_CLAMP, + maxHeight = MAX_HEIGHT, +}) => { const [isExpanded, setIsExpanded] = useState(null); const [isOverflow, descriptionRef] = useIsOverflow(children); @@ -51,7 +59,7 @@ const LineClampComponent: React.FC<{ if (isExpanded) { return ( <> - +

{children}

{isOverflow && ( @@ -70,6 +78,7 @@ const LineClampComponent: React.FC<{ data-test-subj="styled-line-clamp" ref={descriptionRef} lineClampHeight={lineClampHeight} + lineClamp={lineClamp} > {children} diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index 18cb9ef570bd5..fa33d8fa575be 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -6,7 +6,10 @@ */ import { useMemo } from 'react'; -import { LIST_ENTITIES_URL } from '../../../common/entity_analytics/entity_store/constants'; +import { + ENTITY_STORE_INTERNAL_PRIVILEGES_URL, + LIST_ENTITIES_URL, +} from '../../../common/entity_analytics/entity_store/constants'; import type { UploadAssetCriticalityRecordsResponse } from '../../../common/api/entity_analytics/asset_criticality/upload_asset_criticality_csv.gen'; import type { DisableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_disable_route.gen'; import type { RiskEngineStatusResponse } from '../../../common/api/entity_analytics/risk_engine/engine_status_route.gen'; @@ -172,6 +175,15 @@ export const useEntityAnalyticsRoutes = () => { method: 'GET', }); + /** + * Get Entity Store privileges + */ + const fetchEntityStorePrivileges = () => + http.fetch(ENTITY_STORE_INTERNAL_PRIVILEGES_URL, { + version: '1', + method: 'GET', + }); + /** * Create asset criticality */ @@ -295,6 +307,7 @@ export const useEntityAnalyticsRoutes = () => { scheduleNowRiskEngine, fetchRiskEnginePrivileges, fetchAssetCriticalityPrivileges, + fetchEntityStorePrivileges, createAssetCriticality, deleteAssetCriticality, fetchAssetCriticality, diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx index 94a3b6cd48edf..974bdddc831e5 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx @@ -28,6 +28,8 @@ import { ENABLEMENT_DESCRIPTION_RISK_ENGINE_ONLY, ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY, } from '../translations'; +import { useEntityEnginePrivileges } from '../hooks/use_entity_engine_privileges'; +import { MissingPrivilegesCallout } from './missing_privileges_callout'; export interface Enablements { riskScore: boolean; @@ -59,6 +61,7 @@ export const EntityStoreEnablementModal: React.FC } checked={enablements.entityStore} - disabled={entityStore.disabled || false} + disabled={ + entityStore.disabled || (!isLoadingPrivileges && !privileges?.has_all_required) + } onChange={() => setEnablements((prev) => ({ ...prev, entityStore: !prev.entityStore })) } @@ -121,6 +126,11 @@ export const EntityStoreEnablementModal: React.FC + {!privileges || privileges.has_all_required ? null : ( + + + + )} {ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY} diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx new file mode 100644 index 0000000000000..7615f7c33a8f5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx @@ -0,0 +1,114 @@ +/* + * 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 { EuiCallOut, EuiCode, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { LineClamp } from '../../../../common/components/line_clamp'; +import type { EntityAnalyticsPrivileges } from '../../../../../common/api/entity_analytics'; +import { getAllMissingPrivileges } from '../../../../../common/entity_analytics/privileges'; +import { CommaSeparatedValues } from '../../../../detections/components/callouts/missing_privileges_callout/comma_separated_values'; + +interface MissingPrivilegesCalloutProps { + privileges: EntityAnalyticsPrivileges; +} + +/** + * The height of the callout when the content is clamped. + * The value was chosen based on trial and error. + */ +const LINE_CLAMP_HEIGHT = '4.4em'; + +export const MissingPrivilegesCallout = React.memo( + ({ privileges }: MissingPrivilegesCalloutProps) => { + const missingPrivileges = getAllMissingPrivileges(privileges); + const indexPrivileges = missingPrivileges.elasticsearch.index ?? {}; + const clusterPrivileges = missingPrivileges.elasticsearch.cluster ?? {}; + const featurePrivileges = missingPrivileges.kibana; + const id = `missing-entity-store-privileges`; + return ( + + } + iconType={'iInCircle'} + data-test-subj={`callout-${id}`} + data-test-messages={`[${id}]`} + > + + + {indexPrivileges.length > 0 ? ( + <> + +
    + {indexPrivileges.map(({ indexName, privileges: privilege }) => ( +
  • + , + index: {indexName}, + }} + /> +
  • + ))} +
+ + ) : null} + + {clusterPrivileges.length > 0 ? ( + <> + +
    + {clusterPrivileges.map((privilege) => ( +
  • + {privilege} +
  • + ))} +
+ + ) : null} + + {featurePrivileges.length > 0 ? ( + <> + +
    + {featurePrivileges.map((feature) => ( +
  • + {feature}, + }} + /> +
  • + ))} +
+ + ) : null} +
+
+
+ ); + } +); +MissingPrivilegesCallout.displayName = 'MissingPrivilegesCallout'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts new file mode 100644 index 0000000000000..346651df5ed5b --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts @@ -0,0 +1,22 @@ +/* + * 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 { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { SecurityAppError } from '@kbn/securitysolution-t-grid'; +import type { EntityAnalyticsPrivileges } from '../../../../../common/api/entity_analytics'; +import { useEntityAnalyticsRoutes } from '../../../api/api'; + +export const GET_ENTITY_ENGINE_PRIVILEGES = ['get_entity_engine_privileges'] as const; + +export const useEntityEnginePrivileges = (): UseQueryResult< + EntityAnalyticsPrivileges, + SecurityAppError +> => { + const { fetchEntityStorePrivileges } = useEntityAnalyticsRoutes(); + return useQuery(GET_ENTITY_ENGINE_PRIVILEGES, fetchEntityStorePrivileges); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx index a6e85a2c1398a..84648d89f912d 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx @@ -41,6 +41,8 @@ import { useStopEntityEngineMutation, } from '../components/entity_store/hooks/use_entity_store'; import { TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_TOOLTIP } from '../../common/translations'; +import { useEntityEnginePrivileges } from '../components/entity_store/hooks/use_entity_engine_privileges'; +import { MissingPrivilegesCallout } from '../components/entity_store/components/missing_privileges_callout'; const entityStoreEnabledStatuses = ['enabled']; const switchDisabledStatuses = ['error', 'loading', 'installing']; @@ -99,6 +101,8 @@ export const EntityStoreManagementPage = () => { } }, [initEntityEngineMutation, stopEntityEngineMutation, entityStoreStatus]); + const { data: privileges } = useEntityEnginePrivileges(); + if (assetCriticalityIsLoading) { // Wait for permission before rendering content to avoid flickering return null; @@ -284,7 +288,7 @@ export const EntityStoreManagementPage = () => { } alignItems="center" rightSideItems={ - !isEntityStoreFeatureFlagDisabled + !isEntityStoreFeatureFlagDisabled && privileges?.has_all_required ? [ { /> {isEntityStoreFeatureFlagDisabled && } + {!privileges || privileges.has_all_required ? null : ( + <> + + + + + )} + @@ -349,7 +361,9 @@ export const EntityStoreManagementPage = () => { )} {callouts} - {!isEntityStoreFeatureFlagDisabled && canDeleteEntityEngine && } + {!isEntityStoreFeatureFlagDisabled && + privileges?.has_all_required && + canDeleteEntityEngine && } diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx index 3d61c223fd47f..2b219cac38db4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx @@ -74,7 +74,7 @@ const InvestigationGuideViewComponent: React.FC = ( ) : ( - + {ruleNote} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index ebc1706b309f8..5e47428cd1749 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -164,6 +164,7 @@ const createSecuritySolutionRequestContextMock = ( getRiskScoreDataClient: jest.fn(() => clients.riskScoreDataClient), getAssetCriticalityDataClient: jest.fn(() => clients.assetCriticalityDataClient), getAuditLogger: jest.fn(() => mockAuditLogger), + getDataViewsService: jest.fn(), getEntityStoreDataClient: jest.fn(() => clients.entityStoreDataClient), getSiemMigrationsClient: jest.fn(() => clients.siemMigrationsClient), }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts new file mode 100644 index 0000000000000..bdc23dc76008d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts @@ -0,0 +1,76 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { EntityStoreGetPrivilegesResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/get_privileges.gen'; +import { ENTITY_STORE_INTERNAL_PRIVILEGES_URL } from '../../../../../common/entity_analytics/entity_store/constants'; +import { APP_ID, API_VERSIONS } from '../../../../../common/constants'; + +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { getEntityStorePrivileges } from '../utils/get_entity_store_privileges'; +import { buildIndexPatterns } from '../utils'; + +export const entityStoreInternalPrivilegesRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger, + getStartServices: EntityAnalyticsRoutesDeps['getStartServices'] +) => { + router.versioned + .get({ + access: 'internal', + path: ENTITY_STORE_INTERNAL_PRIVILEGES_URL, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: false, + }, + async ( + context, + request, + response + ): Promise> => { + const siemResponse = buildSiemResponse(response); + try { + const [_, { security }] = await getStartServices(); + const { getSpaceId, getAppClient, getDataViewsService } = await context.securitySolution; + + const securitySolution = await context.securitySolution; + securitySolution.getAuditLogger()?.log({ + message: 'User checked if they have the required privileges to use the Entity Store', + event: { + action: `entity_store_privilege_get`, + category: AUDIT_CATEGORY.AUTHENTICATION, + type: AUDIT_TYPE.ACCESS, + outcome: AUDIT_OUTCOME.UNKNOWN, + }, + }); + + const securitySolutionIndices = await buildIndexPatterns( + getSpaceId(), + getAppClient(), + getDataViewsService() + ); + const body = await getEntityStorePrivileges(request, security, securitySolutionIndices); + + return response.ok({ body }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts index 20b6d92d8f0ff..9784dcd619667 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts @@ -12,6 +12,7 @@ import { listEntitiesRoute } from './entities/list'; import { getEntityEngineRoute } from './get'; import { initEntityEngineRoute } from './init'; import { listEntityEnginesRoute } from './list'; +import { entityStoreInternalPrivilegesRoute } from './privileges'; import { startEntityEngineRoute } from './start'; import { stopEntityEngineRoute } from './stop'; @@ -29,4 +30,5 @@ export const registerEntityStoreRoutes = ({ listEntityEnginesRoute(router, logger); listEntitiesRoute(router, logger); applyDataViewIndicesEntityEngineRoute(router, logger); + entityStoreInternalPrivilegesRoute(router, logger, getStartServices); }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts new file mode 100644 index 0000000000000..3d5cf0691c519 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts @@ -0,0 +1,51 @@ +/* + * 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 { KibanaRequest } from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; +import { SO_ENTITY_DEFINITION_TYPE } from '@kbn/entityManager-plugin/server/saved_objects'; +import { RISK_SCORE_INDEX_PATTERN } from '../../../../../common/constants'; +import { + ENTITY_STORE_INDEX_PATTERN, + ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES, +} from '../../../../../common/entity_analytics/entity_store/constants'; +import { checkAndFormatPrivileges } from '../../utils/check_and_format_privileges'; +import { entityEngineDescriptorTypeName } from '../saved_object'; + +export const getEntityStorePrivileges = ( + request: KibanaRequest, + security: SecurityPluginStart, + securitySolutionIndices: string[] +) => { + // The entity store needs access to all security solution indices + const indicesPrivileges = securitySolutionIndices.reduce>( + (acc, index) => { + acc[index] = ['read', 'view_index_metadata']; + return acc; + }, + {} + ); + + // The entity store has to create the following indices + indicesPrivileges[ENTITY_STORE_INDEX_PATTERN] = ['read', 'manage']; + indicesPrivileges[RISK_SCORE_INDEX_PATTERN] = ['read', 'manage']; + + return checkAndFormatPrivileges({ + request, + security, + privilegesToCheck: { + kibana: [ + security.authz.actions.savedObject.get(entityEngineDescriptorTypeName, 'create'), + security.authz.actions.savedObject.get(SO_ENTITY_DEFINITION_TYPE, 'create'), + ], + elasticsearch: { + cluster: ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES, + index: indicesPrivileges, + }, + }, + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts index 189e72624c15c..a76fc2db4d669 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts @@ -19,6 +19,7 @@ const createMockSecurityStartWithFullRiskEngineAccess = () => { 'index-name': ['read'], }, }, + kibana: [], }, }); @@ -39,6 +40,7 @@ const createMockSecurityStartWithNoRiskEngineAccess = () => { cluster: [], index: [], }, + kibana: [], }, }); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts index 04f4e95272116..6b2b806f1e408 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts @@ -54,6 +54,7 @@ describe('_formatPrivileges', () => { }, }, }, + kibana: {}, }); }); @@ -84,6 +85,7 @@ describe('_formatPrivileges', () => { monitor: true, }, }, + kibana: {}, }); }); @@ -145,6 +147,7 @@ describe('_formatPrivileges', () => { }, }, }, + kibana: {}, }); }); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts index 713405b11d5e8..16b454828a381 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts @@ -29,6 +29,7 @@ export const _formatPrivileges = ( privileges: CheckPrivilegesResponse['privileges'] ): EntityAnalyticsPrivileges['privileges'] => { const clusterPrivilegesByPrivilege = groupPrivilegesByName(privileges.elasticsearch.cluster); + const kibanaPrivilegesByPrivilege = groupPrivilegesByName(privileges.kibana); const indexPrivilegesByIndex = Object.entries(privileges.elasticsearch.index).reduce< Record> @@ -50,13 +51,16 @@ export const _formatPrivileges = ( } : {}), }, + kibana: { + ...(Object.keys(kibanaPrivilegesByPrivilege).length > 0 ? kibanaPrivilegesByPrivilege : {}), + }, }; }; interface CheckAndFormatPrivilegesOpts { request: KibanaRequest; security: SecurityPluginStart; - privilegesToCheck: Pick; + privilegesToCheck: CheckPrivilegesPayload; } export async function checkAndFormatPrivileges({ diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index bd5c29651e26e..2907c3a57ac72 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -135,6 +135,8 @@ export class RequestContextFactory implements IRequestContextFactory { getAuditLogger, + getDataViewsService: () => dataViewsService, + getDetectionRulesClient: memoize(() => { const mlAuthz = buildMlAuthz({ license: licensing.license, diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 1355904dbe7f7..5d8a168548a22 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -20,6 +20,7 @@ import type { AlertsClient, IRuleDataService } from '@kbn/rule-registry-plugin/s import type { Readable } from 'stream'; import type { AuditLogger } from '@kbn/security-plugin-types-server'; +import type { DataViewsService } from '@kbn/data-views-plugin/common'; import type { Immutable } from '../common/endpoint/types'; import { AppClient } from './client'; import type { ConfigType } from './config'; @@ -52,6 +53,7 @@ export interface SecuritySolutionApiRequestHandlerContext { getRuleExecutionLog: () => IRuleExecutionLogForRoutes; getRacClient: (req: KibanaRequest) => Promise; getAuditLogger: () => AuditLogger | undefined; + getDataViewsService: () => DataViewsService; getExceptionListClient: () => ExceptionListClient | null; getInternalFleetServices: () => EndpointInternalFleetServicesInterface; getRiskEngineDataClient: () => RiskEngineDataClient; diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index 3503f07fec574..4a7efdc167299 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -699,6 +699,13 @@ If a record already exists for the specified entity, that record is overwritten .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + entityStoreGetPrivileges(kibanaSpace: string = 'default') { + return supertest + .get(routeWithNamespace('/internal/entity_store/privileges', kibanaSpace)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Export detection rules to an `.ndjson` file. The following configuration items are also included in the `.ndjson` file: - Actions diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts index 7b35787cafe24..bd22e51a6a551 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts @@ -104,6 +104,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); it('returns has_all_required false for user without asset criticality index read', async () => { @@ -118,6 +119,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); it('returns has_all_required false for user without asset criticality index write', async () => { @@ -132,6 +134,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); }); @@ -157,6 +160,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); @@ -175,6 +179,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_privileges.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_privileges.ts index 1336aa5c21ac1..6b4639030e785 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_privileges.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_privileges.ts @@ -121,6 +121,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); it('returns has_all_required false for user with no write access to risk indices', async () => { @@ -139,6 +140,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); it('returns has_all_required false for user with no read access to risk indices', async () => { @@ -157,6 +159,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); it('returns has_all_required false for user with no cluster manage transform privilege', async () => { @@ -175,6 +178,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); it('returns has_all_required false for user with no cluster manage index templates privilege', async () => { @@ -193,6 +197,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, + kibana: {}, }); }); });