Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
@@ -0,0 +1,16 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

// TODO: Asset Inventory - This file is a placeholder for the ECS schema that will be used in the Asset Inventory app
export interface EntityEcs {
id: string;
name: string;
type: 'universal' | 'user' | 'host' | 'service';
timestamp: Date;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export const CHANGE_RULE_STATE = 'change-rule-state' as const;
export const GRAPH_PREVIEW = 'graph-preview' as const;
export const GRAPH_INVESTIGATION = 'graph-investigation' as const;

export const ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS =
'asset-inventory-expand-flyout-success' as const;
export const ASSET_INVENTORY_EXPAND_FLYOUT_ERROR = 'asset-inventory-expand-flyout-error' as const;
export const UNIVERSAL_ENTITY_FLYOUT_OPENED = 'universal-entity-flyout-opened' as const;

export type CloudSecurityUiCounters =
| typeof ENTITY_FLYOUT_WITH_MISCONFIGURATION_VISIT
| typeof ENTITY_FLYOUT_WITH_VULNERABILITY_PREVIEW
Expand All @@ -68,7 +73,10 @@ export type CloudSecurityUiCounters =
| typeof VULNERABILITIES_INSIGHT_HOST_DETAILS
| typeof VULNERABILITIES_INSIGHT_HOST_ENTITY_OVERVIEW
| typeof GRAPH_PREVIEW
| typeof GRAPH_INVESTIGATION;
| typeof GRAPH_INVESTIGATION
| typeof ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS
| typeof ASSET_INVENTORY_EXPAND_FLYOUT_ERROR
| typeof UNIVERSAL_ENTITY_FLYOUT_OPENED;

export class UiMetricService {
private usageCollection: UsageCollectionSetup | undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
* 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 { renderHook, act } from '@testing-library/react';
import { useDynamicEntityFlyout } from './use_dynamic_entity_flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useKibana } from '../../common/lib/kibana';
import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_expandable_flyout_close';
import {
UniversalEntityPanelKey,
UserPanelKey,
HostPanelKey,
ServicePanelKey,
} from '../../flyout/entity_details/shared/constants';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';

jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
}));

jest.mock('../../common/lib/kibana', () => ({
useKibana: jest.fn(),
}));

jest.mock('../../flyout/shared/hooks/use_on_expandable_flyout_close', () => ({
useOnExpandableFlyoutClose: jest.fn(),
}));

const entity = {
id: '123',
name: 'test-entity',
type: 'universal',
timestamp: new Date(),
};

describe('useDynamicEntityFlyout', () => {
let openFlyoutMock: jest.Mock;
let closeFlyoutMock: jest.Mock;
let toastsMock: { addDanger: jest.Mock };
let onFlyoutCloseMock: jest.Mock;

beforeEach(() => {
openFlyoutMock = jest.fn();
closeFlyoutMock = jest.fn();
toastsMock = { addDanger: jest.fn() };
onFlyoutCloseMock = jest.fn();

(useExpandableFlyoutApi as jest.Mock).mockReturnValue({
openFlyout: openFlyoutMock,
closeFlyout: closeFlyoutMock,
});
(useKibana as jest.Mock).mockReturnValue({
services: { notifications: { toasts: toastsMock } },
});
(useOnExpandableFlyoutClose as jest.Mock).mockImplementation(({ callback }) => callback);
});

it('should open the flyout with correct params for a universal entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);

act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'universal', name: 'testUniversal' },
scopeId: 'scope1',
contextId: 'context1',
});
});

expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UniversalEntityPanelKey,
params: { entity: { ...entity, type: 'universal', name: 'testUniversal' } },
},
});
});

it('should open the flyout with correct params for a user entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);

act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'user', name: 'testUser' },
scopeId: 'scope1',
contextId: 'context1',
});
});

expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UserPanelKey,
params: { userName: 'testUser', scopeId: 'scope1', contextId: 'context1' },
},
});
});

it('should open the flyout with correct params for a host entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);

act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'host', name: 'testHost' },
scopeId: 'scope1',
contextId: 'context1',
});
});

expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: HostPanelKey,
params: { hostName: 'testHost', scopeId: 'scope1', contextId: 'context1' },
},
});
});

it('should open the flyout with correct params for a service entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);

act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'service', name: 'testService' },
scopeId: 'scope1',
contextId: 'context1',
});
});

expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: ServicePanelKey,
params: { serviceName: 'testService', scopeId: 'scope1', contextId: 'context1' },
},
});
});

it('should show an error toast and close flyout if entity name is missing for user, host, or service entities', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);

act(() => {
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs });
});

expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();

act(() => {
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs });
});

expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();

act(() => {
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs });
});

expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
});

it('should close the flyout when closeDynamicFlyout is called', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);

act(() => {
result.current.closeDynamicFlyout();
});

expect(closeFlyoutMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import {
ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS,
ASSET_INVENTORY_EXPAND_FLYOUT_ERROR,
uiMetricService,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../common/lib/kibana';
import {
HostPanelKey,
ServicePanelKey,
UniversalEntityPanelKey,
UserPanelKey,
} from '../../flyout/entity_details/shared/constants';
import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_expandable_flyout_close';

interface InventoryFlyoutProps {
entity: EntityEcs;
scopeId?: string;
contextId?: string;
}

interface SecurityFlyoutPanelsCommonParams {
scopeId?: string;
contextId?: string;
[key: string]: unknown;
}

type FlyoutParams =
| {
id: typeof UniversalEntityPanelKey;
params: { entity: EntityEcs };
}
| { id: typeof UserPanelKey; params: { userName: string } & SecurityFlyoutPanelsCommonParams }
| { id: typeof HostPanelKey; params: { hostName: string } & SecurityFlyoutPanelsCommonParams }
| {
id: typeof ServicePanelKey;
params: { serviceName: string } & SecurityFlyoutPanelsCommonParams;
};

const getFlyoutParamsByEntity = ({
entity,
scopeId,
contextId,
}: InventoryFlyoutProps): FlyoutParams => {
const entitiesFlyoutParams: Record<EntityEcs['type'], FlyoutParams> = {
universal: { id: UniversalEntityPanelKey, params: { entity } },
user: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
host: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
service: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
} as const;

return entitiesFlyoutParams[entity.type];
};

export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () => void }) => {
const { openFlyout, closeFlyout } = useExpandableFlyoutApi();
const { notifications } = useKibana().services;
useOnExpandableFlyoutClose({ callback: onFlyoutClose });

const openDynamicFlyout = ({ entity, scopeId, contextId }: InventoryFlyoutProps) => {
const entityFlyoutParams = getFlyoutParamsByEntity({ entity, scopeId, contextId });

// User, Host, and Service entity flyouts rely on entity name to fetch required data
if (entity.type !== 'universal' && !entity.name) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
if (entity.type !== 'universal' && !entity.name) {
if (entity.type !== UNIVERSAL_ENTITY_TYPE_UNIVERSAL && !entity.name) {

Not sure if this needs a constant right now, mainly because I would prefer it to be coming from ECS and not some local value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would prefer it to be coming from ECS and not some local value

I agree, it would be very beneficial for consistency to have the entity.category types under ECS, just like the event field.

For consistency, I also think there's a huge benefit from having the entity.category values defined from the ECS schema, as we currently do for the event.category field. That would help us to track the source of truth outside of Kibana. cc @tinnytintin10

notifications.toasts.addDanger({
title: i18n.translate(
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameTitle',
{ defaultMessage: 'Missing Entity Name' }
),
text: i18n.translate(
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameText',
{ defaultMessage: 'Entity name is required for User, Host, and Service entities' }
),
});

uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_ERROR);
onFlyoutClose();
return;
}

uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS);

openFlyout({
right: {
id: entityFlyoutParams.id || UniversalEntityPanelKey,
params: entityFlyoutParams.params,
},
});
};

const closeDynamicFlyout = () => {
closeFlyout();
};
Comment on lines +101 to +103
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Once we'll add the details and preview panels, this function will expand. right now its mostly for comfort so you dont have to import both this hook and the expandable flyout api separately just to close the flyout


return {
openDynamicFlyout,
closeDynamicFlyout,
};
};
Loading