Skip to content

Commit

Permalink
[SecuritySolution] Update Entity Store Dashboard to prompt for Servic…
Browse files Browse the repository at this point in the history
…e Entity Type (elastic#207336)

## Summary

* Display a callout to install uninstalled entity types
* It will be displayed when the entity store is running, but some
available entity types are not installed
* Add `entityTypes` param to the init entity store API
* Enable `serviceEntityStoreEnabled` flag by default
### Checklist


### How to test it?
* Disable the experimental flag on
`x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts`
* Open `Manage/Entity Store/Engine Status`
* Verify that the service entity store is NOT installed
* Reenable the flag on
`x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts`
* Open the Entity Dashboard page
* It should display a callout to install more entity types
* Click "enable"
* It should install the service entity store
* Open `Manage/Entity Store/Engine Status`
* Verify that the service entity store IS installed

Reviewers should verify this PR satisfies this list as well.

- [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
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
machadoum and kibanamachine authored Jan 23, 2025
1 parent 6850ba7 commit 5c59f3b
Show file tree
Hide file tree
Showing 15 changed files with 163 additions and 33 deletions.
4 changes: 4 additions & 0 deletions oas_docs/output/kibana.serverless.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9721,6 +9721,10 @@ paths:
schema:
type: object
properties:
entityTypes:
items:
$ref: '#/components/schemas/Security_Entity_Analytics_API_EntityType'
type: array
fieldHistoryLength:
default: 10
description: The number of historical values to keep for each field.
Expand Down
4 changes: 4 additions & 0 deletions oas_docs/output/kibana.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11892,6 +11892,10 @@ paths:
schema:
type: object
properties:
entityTypes:
items:
$ref: '#/components/schemas/Security_Entity_Analytics_API_EntityType'
type: array
fieldHistoryLength:
default: 10
description: The number of historical values to keep for each field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { z } from '@kbn/zod';

import { IndexPattern, EngineDescriptor } from './common.gen';
import { IndexPattern, EntityType, EngineDescriptor } from './common.gen';

export type InitEntityStoreRequestBody = z.infer<typeof InitEntityStoreRequestBody>;
export const InitEntityStoreRequestBody = z.object({
Expand All @@ -26,6 +26,7 @@ export const InitEntityStoreRequestBody = z.object({
fieldHistoryLength: z.number().int().optional().default(10),
indexPattern: IndexPattern.optional(),
filter: z.string().optional(),
entityTypes: z.array(EntityType).optional(),
});
export type InitEntityStoreRequestBodyInput = z.input<typeof InitEntityStoreRequestBody>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ paths:
$ref: './common.schema.yaml#/components/schemas/IndexPattern'
filter:
type: string
entityTypes:
type: array
items:
$ref: './common.schema.yaml#/components/schemas/EntityType'
responses:
'200':
description: Successful response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('parseAssetCriticalityCsvRow', () => {

// @ts-ignore result can now only be InvalidRecord
expect(result.error).toMatchInlineSnapshot(
`"Invalid entity type \\"invalid\\", expected to be one of: user, host"`
`"Invalid entity type \\"invalid\\", expected to be one of: user, host, service"`
);
});

Expand All @@ -68,7 +68,7 @@ describe('parseAssetCriticalityCsvRow', () => {

// @ts-ignore result can now only be InvalidRecord
expect(result.error).toMatchInlineSnapshot(
`"Invalid entity type \\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\\", expected to be one of: user, host"`
`"Invalid entity type \\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...\\", expected to be one of: user, host, service"`
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export const allowedExperimentalValues = Object.freeze({
/**
* Enables the Service Entity Store. The Entity Store feature will install the service engine by default.
*/
serviceEntityStoreEnabled: false,
serviceEntityStoreEnabled: true,

/**
* Enables the siem migrations feature
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ paths:
schema:
type: object
properties:
entityTypes:
items:
$ref: '#/components/schemas/EntityType'
type: array
fieldHistoryLength:
default: 10
description: The number of historical values to keep for each field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ paths:
schema:
type: object
properties:
entityTypes:
items:
$ref: '#/components/schemas/EntityType'
type: array
fieldHistoryLength:
default: 10
description: The number of historical values to keep for each field.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { useMemo } from 'react';
import type { GetEntityStoreStatusResponse } from '../../../common/api/entity_analytics/entity_store/status.gen';
import type {
InitEntityStoreRequestBody,
InitEntityStoreRequestBodyInput,
InitEntityStoreResponse,
} from '../../../common/api/entity_analytics/entity_store/enable.gen';
import type {
Expand All @@ -24,9 +24,7 @@ export const useEntityStoreRoutes = () => {
const http = useKibana().services.http;

return useMemo(() => {
const enableEntityStore = async (
options: InitEntityStoreRequestBody = { fieldHistoryLength: 10 }
) => {
const enableEntityStore = async (options: InitEntityStoreRequestBodyInput = {}) => {
return http.fetch<InitEntityStoreResponse>('/api/entity_store/enable', {
method: 'POST',
version: API_VERSIONS.public.v1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { UseQueryResult } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/status.gen';
import type {
RiskEngineStatusResponse,
Expand All @@ -37,6 +38,7 @@ import {
import type { Enablements } from './enablement_modal';
import { EntityStoreEnablementModal } from './enablement_modal';
import dashboardEnableImg from '../../../images/entity_store_dashboard.png';
import { useStoreEntityTypes } from '../../../hooks/use_enabled_entity_types';

interface EnableEntityStorePanelProps {
state: {
Expand All @@ -48,6 +50,8 @@ interface EnableEntityStorePanelProps {
export const EnablementPanel: React.FC<EnableEntityStorePanelProps> = ({ state }) => {
const riskEngineStatus = state.riskEngine.data?.risk_engine_status;
const entityStoreStatus = state.entityStore.data?.status;
const engines = state.entityStore.data?.engines;
const enabledEntityTypes = useStoreEntityTypes();

const [modal, setModalState] = useState({ visible: false });
const [riskEngineInitializing, setRiskEngineInitializing] = useState(false);
Expand All @@ -62,7 +66,7 @@ export const EnablementPanel: React.FC<EnableEntityStorePanelProps> = ({ state }
onSuccess: () => {
setRiskEngineInitializing(false);
if (enable.entityStore) {
storeEnablement.mutate();
storeEnablement.mutate({});
}
},
};
Expand All @@ -73,29 +77,36 @@ export const EnablementPanel: React.FC<EnableEntityStorePanelProps> = ({ state }
}

if (enable.entityStore) {
storeEnablement.mutate();
storeEnablement.mutate({});
setModalState({ visible: false });
}
},
[storeEnablement, initRiskEngine]
);

const installedTypes = engines?.map((engine) => engine.type);
const uninstalledTypes = enabledEntityTypes.filter(
(type) => !(installedTypes || []).includes(type)
);

const enableUninstalledEntityStore = () => {
storeEnablement.mutate({ entityTypes: uninstalledTypes });
};

if (storeEnablement.error) {
return (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.mutation.errorTitle"
defaultMessage={'There was a problem initializing the entity store'}
/>
}
color="danger"
iconType="error"
>
<p>{storeEnablement.error.body.message}</p>
</EuiCallOut>
</>
<EuiCallOut
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.mutation.errorTitle"
defaultMessage={'There was a problem initializing the entity store'}
/>
}
color="danger"
iconType="error"
>
<p>{storeEnablement.error.body.message}</p>
</EuiCallOut>
);
}

Expand Down Expand Up @@ -129,6 +140,51 @@ export const EnablementPanel: React.FC<EnableEntityStorePanelProps> = ({ state }
);
}

if (entityStoreStatus === 'running' && uninstalledTypes.length > 0) {
const title = i18n.translate(
'xpack.securitySolution.entityAnalytics.entityStore.enablement.moreEntityTypesTitle',
{
defaultMessage: 'More entity types available',
}
);

return (
<EuiEmptyPrompt
css={{ minWidth: '100%' }}
hasBorder
layout="horizontal"
actions={
<EuiToolTip content={title}>
<EuiButton
color="primary"
fill
onClick={enableUninstalledEntityStore}
data-test-subj={`entityStoreEnablementButton`}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.enableButton"
defaultMessage="Enable"
/>
</EuiButton>
</EuiToolTip>
}
icon={<EuiImage size="l" hasShadow src={dashboardEnableImg} alt={title} />}
data-test-subj="entityStoreEnablementPanel"
title={<h2>{title}</h2>}
body={
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.moreEntityTypes"
defaultMessage={
'Enable missing types in the entity store to capture even more entities observed in events'
}
/>
</p>
}
/>
);
}

if (
riskEngineStatus !== RiskEngineStatusEnum.NOT_INSTALLED &&
(entityStoreStatus === 'running' || entityStoreStatus === 'stopped')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/status.gen';
import type { InitEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/enable.gen';
import type {
InitEntityStoreRequestBodyInput,
InitEntityStoreResponse,
} from '../../../../../common/api/entity_analytics/entity_store/enable.gen';
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
import type { EntityType } from '../../../../../common/api/entity_analytics';
import {
Expand Down Expand Up @@ -47,18 +50,28 @@ export const useEntityStoreStatus = (opts: Options = {}) => {
};

export const ENABLE_STORE_STATUS_KEY = ['POST', 'ENABLE_ENTITY_STORE'];
export const useEnableEntityStoreMutation = (options?: UseMutationOptions<{}>) => {
export const useEnableEntityStoreMutation = (
options?: UseMutationOptions<
InitEntityStoreResponse,
ResponseError,
InitEntityStoreRequestBodyInput
>
) => {
const { telemetry } = useKibana().services;
const queryClient = useQueryClient();
const { enableEntityStore } = useEntityStoreRoutes();

return useMutation<InitEntityStoreResponse, ResponseError>(
() => {
return useMutation<
InitEntityStoreResponse,
ResponseError,
Partial<InitEntityStoreRequestBodyInput>
>(
(params) => {
telemetry?.reportEvent(EntityEventTypes.EntityStoreEnablementToggleClicked, {
timestamp: new Date().toISOString(),
action: 'start',
});
return enableEntityStore();
return enableEntityStore(params);
},
{
mutationKey: ENABLE_STORE_STATUS_KEY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const EntityStoreManagementPage = () => {
if (isEntityStoreEnabled(entityStoreStatus.data?.status)) {
stopEntityEngineMutation.mutate();
} else {
enableStoreMutation.mutate();
enableStoreMutation.mutate({});
}
}, [entityStoreStatus.data?.status, stopEntityEngineMutation, enableStoreMutation]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { mockGlobalState } from '../../../../public/common/mock';
import type { EntityDefinition } from '@kbn/entities-schema';
import { convertToEntityManagerDefinition } from './entity_definitions/entity_manager_conversion';
import { EntityType } from '../../../../common/search_strategy';
import type { InitEntityEngineResponse } from '../../../../common/api/entity_analytics';
import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';

const definition: EntityDefinition = convertToEntityManagerDefinition(
{
Expand Down Expand Up @@ -56,6 +58,7 @@ describe('EntityStoreDataClient', () => {
appClient: {} as AppClient,
config: {} as EntityStoreConfig,
experimentalFeatures: mockGlobalState.app.enableExperimental,
taskManager: {} as TaskManagerStartContract,
});

const defaultSearchParams = {
Expand Down Expand Up @@ -338,4 +341,33 @@ describe('EntityStoreDataClient', () => {
]);
});
});

describe('enable entities', () => {
let spyInit: jest.SpyInstance;

beforeEach(() => {
jest.resetAllMocks();
spyInit = jest
.spyOn(dataClient, 'init')
.mockImplementation(() => Promise.resolve({} as InitEntityEngineResponse));
});

it('only enable engine for the given entityType', async () => {
await dataClient.enable({
entityTypes: [EntityType.host],
fieldHistoryLength: 1,
});

expect(spyInit).toHaveBeenCalledWith(EntityType.host, expect.anything(), expect.anything());
});

it('does not enable engine when the given entity type is disabled', async () => {
await dataClient.enable({
entityTypes: [EntityType.universal],
fieldHistoryLength: 1,
});

expect(spyInit).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,12 @@ export class EntityStoreDataClient {
}

public async enable(
{ indexPattern = '', filter = '', fieldHistoryLength = 10 }: InitEntityStoreRequestBody,
{
indexPattern = '',
filter = '',
fieldHistoryLength = 10,
entityTypes,
}: InitEntityStoreRequestBody,
{ pipelineDebugMode = false }: { pipelineDebugMode?: boolean } = {}
): Promise<InitEntityStoreResponse> {
if (!this.options.taskManager) {
Expand All @@ -215,7 +220,12 @@ export class EntityStoreDataClient {
new Promise<T>((resolve) => setTimeout(() => fn().then(resolve), 0));

const { experimentalFeatures } = this.options;
const enginesTypes = getEnabledStoreEntityTypes(experimentalFeatures);
const enabledEntityTypes = getEnabledStoreEntityTypes(experimentalFeatures);

// When entityTypes param is defined it only enables the engines that are provided
const enginesTypes = entityTypes
? (entityTypes as EntityType[]).filter((type) => enabledEntityTypes.includes(type))
: enabledEntityTypes;

const promises = enginesTypes.map((entity) =>
run(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('calculateRiskScores()', () => {
expect(response).toHaveProperty('scores');
expect(response.scores.host).toHaveLength(2);
expect(response.scores.user).toHaveLength(2);
expect(response.scores.service).toHaveLength(0);
expect(response.scores.service).toHaveLength(2);
});

it('calculates risk score for service when the experimental flag is enabled', async () => {
Expand Down

0 comments on commit 5c59f3b

Please sign in to comment.