[8.4] [Kubernetes Security] Tree nav group by both cluster id and name (#137858)

* Tree nav group by both cluster id and name

* Update and render cluster name correctly while only using cluster id in filters

* Add more tests to tree view breadcrumb

* Add multi terms aggregate route tests
This commit is contained in:
Jack 2022-08-04 13:14:31 -04:00 committed by GitHub
parent d84fa4406b
commit ccdb68a90a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 783 additions and 58 deletions

View file

@ -11,6 +11,7 @@ export const LOCAL_STORAGE_HIDE_WIDGETS_KEY = 'kubernetesSecurity:shouldHideWidg
export const AGGREGATE_ROUTE = '/internal/kubernetes_security/aggregate';
export const COUNT_ROUTE = '/internal/kubernetes_security/count';
export const MULTI_TERMS_AGGREGATE_ROUTE = '/internal/kubernetes_security/multi_terms_aggregate';
export const AGGREGATE_PAGE_SIZE = 10;
// so, bucket sort can only page through what we request at the top level agg, which means there is a ceiling to how many aggs we can page through.
@ -28,11 +29,12 @@ export const ENTRY_LEADER_INTERACTIVE = 'process.entry_leader.interactive';
export const ENTRY_LEADER_USER_ID = 'process.entry_leader.user.id';
export const ENTRY_LEADER_ENTITY_ID = 'process.entry_leader.entity_id';
export const ORCHESTRATOR_CLUSTER_ID = 'orchestrator.cluster.name';
export const ORCHESTRATOR_CLUSTER_ID = 'orchestrator.cluster.id';
export const ORCHESTRATOR_CLUSTER_NAME = 'orchestrator.cluster.name';
export const ORCHESTRATOR_NAMESPACE = 'orchestrator.namespace';
export const CLOUD_INSTANCE_NAME = 'cloud.instance.name';
export const ORCHESTRATOR_RESOURCE_ID = 'orchestrator.resource.name';
export const CONTAINER_IMAGE_NAME = 'container.image.name';
export const CLOUD_INSTANCE_NAME = 'cloud.instance.name';
export const COUNT_WIDGET_KEY_CLUSTERS = 'CountClustersWidget';
export const COUNT_WIDGET_KEY_NAMESPACE = 'CountNamespaceWidgets';

View file

@ -10,18 +10,19 @@ interface Aggregate {
doc_count: number;
}
interface Buckets extends Aggregate {
interface Bucket extends Aggregate {
key_as_string?: string;
count_by_aggs: {
count_by_aggs?: {
value: number;
};
}
export interface AggregateResult {
buckets: Buckets[];
buckets: Bucket[];
hasNextPage: boolean;
}
export interface AggregateBucketPaginationResult {
buckets: Aggregate[];
buckets: Bucket[];
hasNextPage: boolean;
}

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
export interface MultiTermsAggregateGroupBy {
field: string;
maybe?: string;
}
interface MultiTermsAggregate {
key: Array<string | number | boolean>;
doc_count: number;
}
export interface Bucket extends MultiTermsAggregate {
key_as_string?: string;
count_by_aggs?: {
value: number;
};
}
export interface MultiTermsAggregateResult {
buckets: Bucket[];
hasNextPage: boolean;
}
export interface MultiTermsAggregateBucketPaginationResult {
buckets: Bucket[];
hasNextPage: boolean;
}

View file

@ -171,7 +171,7 @@ export const ContainerNameWidget = ({
return aggsData?.buckets.map((aggData) => {
return {
name: aggData.key as string,
count: addCommasToNumber(aggData.count_by_aggs.value),
count: addCommasToNumber(aggData.count_by_aggs?.value ?? 0),
};
});
})

View file

@ -71,7 +71,7 @@ const KubernetesSecurityRoutesComponent = ({
(result: AggregateResult): Record<string, number> =>
result.buckets.reduce((groupedByKeyValue, aggregate) => {
groupedByKeyValue[aggregate.key_as_string || (aggregate.key.toString() as string)] =
aggregate.count_by_aggs.value;
aggregate.count_by_aggs?.value ?? 0;
return groupedByKeyValue;
}, {} as Record<string, number>),
[]
@ -81,10 +81,10 @@ const KubernetesSecurityRoutesComponent = ({
(result: AggregateResult): Record<string, number> =>
result.buckets.reduce((groupedByKeyValue, aggregate) => {
if (aggregate.key.toString() === '0') {
groupedByKeyValue[aggregate.key] = aggregate.count_by_aggs.value;
groupedByKeyValue[aggregate.key] = aggregate.count_by_aggs?.value ?? 0;
} else {
groupedByKeyValue.nonRoot =
(groupedByKeyValue.nonRoot || 0) + aggregate.count_by_aggs.value;
(groupedByKeyValue.nonRoot || 0) + (aggregate.count_by_aggs?.value ?? 0);
}
return groupedByKeyValue;
}, {} as Record<string, number>),

View file

@ -22,7 +22,7 @@ Object {
<span
class="euiButtonEmpty__text"
>
selected cluster
selected cluster name
</span>
</span>
</button>
@ -113,7 +113,7 @@ Object {
<span
class="euiButtonEmpty__text"
>
selected cluster
selected cluster name
</span>
</span>
</button>
@ -261,7 +261,7 @@ Object {
<span
class="euiButtonEmpty__text"
>
selected cluster
selected cluster name
</span>
</span>
</button>
@ -330,7 +330,7 @@ Object {
<span
class="euiButtonEmpty__text"
>
selected cluster
selected cluster name
</span>
</span>
</button>
@ -433,3 +433,242 @@ Object {
"unmount": [Function],
}
`;
exports[`Tree view Breadcrumb component When Breadcrumb is mounted returns cluster id when no cluster name is provided 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<div
css="[object Object]"
>
<span
color="success"
data-euiicon-type="heatmap"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected cluster id
</span>
</span>
</button>
<span
class="emotion-EuiIcon"
data-euiicon-type="arrowRight"
/>
<span
color="primary"
data-euiicon-type="nested"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected namespace
</span>
</span>
</button>
<span
class="emotion-EuiIcon"
data-euiicon-type="arrowRight"
/>
<span
color="warning"
data-euiicon-type="package"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected pod
</span>
</span>
</button>
<span
class="emotion-EuiIcon"
data-euiicon-type="arrowRight"
/>
<span
color="danger"
data-euiicon-type="image"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected image
</span>
</span>
</button>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<span
color="success"
data-euiicon-type="heatmap"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected cluster id
</span>
</span>
</button>
<span
class="emotion-EuiIcon"
data-euiicon-type="arrowRight"
/>
<span
color="primary"
data-euiicon-type="nested"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected namespace
</span>
</span>
</button>
<span
class="emotion-EuiIcon"
data-euiicon-type="arrowRight"
/>
<span
color="warning"
data-euiicon-type="package"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected pod
</span>
</span>
</button>
<span
class="emotion-EuiIcon"
data-euiicon-type="arrowRight"
/>
<span
color="danger"
data-euiicon-type="image"
/>
<button
class="euiButtonEmpty euiButtonEmpty--text emotion-EuiButtonEmpty"
type="button"
>
<span
class="euiButtonContent euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
>
selected image
</span>
</span>
</button>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View file

@ -11,7 +11,8 @@ import { KubernetesCollection, TreeNavSelection } from '../../../types';
import { Breadcrumb } from '.';
const MOCK_TREE_SELECTION: TreeNavSelection = {
[KubernetesCollection.cluster]: 'selected cluster',
[KubernetesCollection.clusterId]: 'selected cluster id',
[KubernetesCollection.clusterName]: 'selected cluster name',
[KubernetesCollection.namespace]: 'selected namespace',
[KubernetesCollection.node]: 'selected node',
[KubernetesCollection.pod]: 'selected pod',
@ -39,7 +40,7 @@ describe('Tree view Breadcrumb component', () => {
);
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.cluster]!)
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.clusterName]!)
).toBeVisible();
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.namespace]!)
@ -60,10 +61,38 @@ describe('Tree view Breadcrumb component', () => {
expect(renderResult.container).toBeEmptyDOMElement();
});
it('returns cluster id when no cluster name is provided', async () => {
renderResult = mockedContext.render(
<Breadcrumb
treeNavSelection={{
...MOCK_TREE_SELECTION,
[KubernetesCollection.clusterName]: undefined,
[KubernetesCollection.node]: undefined,
}}
onSelect={onSelect}
/>
);
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.clusterId]!)
).toBeVisible();
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.namespace]!)
).toBeVisible();
expect(renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.node]!)).toBeFalsy();
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.pod]!)
).toBeVisible();
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.containerImage]!)
).toBeVisible();
expect(renderResult).toMatchSnapshot();
});
it('returns null when no cluster in selection', async () => {
renderResult = mockedContext.render(
<Breadcrumb
treeNavSelection={{ ...MOCK_TREE_SELECTION, [KubernetesCollection.cluster]: undefined }}
treeNavSelection={{ ...MOCK_TREE_SELECTION, [KubernetesCollection.clusterId]: undefined }}
onSelect={onSelect}
/>
);
@ -71,11 +100,25 @@ describe('Tree view Breadcrumb component', () => {
expect(renderResult.container).toBeEmptyDOMElement();
});
it('clicking on breadcrumb item triggers onSelect', async () => {
renderResult = mockedContext.render(
<Breadcrumb
treeNavSelection={{ ...MOCK_TREE_SELECTION, [KubernetesCollection.node]: undefined }}
onSelect={onSelect}
/>
);
renderResult.getByText(MOCK_TREE_SELECTION[KubernetesCollection.clusterName]!).click();
expect(onSelect).toHaveBeenCalledTimes(1);
});
it('renders provided collections only', async () => {
renderResult = mockedContext.render(
<Breadcrumb
treeNavSelection={{
[KubernetesCollection.cluster]: MOCK_TREE_SELECTION[KubernetesCollection.cluster],
[KubernetesCollection.clusterId]: MOCK_TREE_SELECTION[KubernetesCollection.clusterId],
[KubernetesCollection.clusterName]:
MOCK_TREE_SELECTION[KubernetesCollection.clusterName],
[KubernetesCollection.node]: MOCK_TREE_SELECTION[KubernetesCollection.node],
[KubernetesCollection.containerImage]:
MOCK_TREE_SELECTION[KubernetesCollection.containerImage],
@ -85,7 +128,7 @@ describe('Tree view Breadcrumb component', () => {
);
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.cluster]!)
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.clusterName]!)
).toBeVisible();
expect(
renderResult.queryByText(MOCK_TREE_SELECTION[KubernetesCollection.namespace]!)

View file

@ -21,9 +21,10 @@ export const Breadcrumb = ({ treeNavSelection, onSelect }: BreadcrumbDeps) => {
(collectionType: string) => {
const selectionCopy = { ...treeNavSelection };
switch (collectionType) {
case KubernetesCollection.cluster: {
case KubernetesCollection.clusterId: {
onSelect({
[KubernetesCollection.cluster]: treeNavSelection[KubernetesCollection.cluster],
[KubernetesCollection.clusterId]: treeNavSelection[KubernetesCollection.clusterId],
[KubernetesCollection.clusterName]: treeNavSelection[KubernetesCollection.clusterName],
});
break;
}
@ -59,7 +60,10 @@ export const Breadcrumb = ({ treeNavSelection, onSelect }: BreadcrumbDeps) => {
color="text"
onClick={() => onBreadCrumbClick(collectionType)}
>
{treeNavSelection[collectionType]}
{collectionType === KubernetesCollection.clusterId
? treeNavSelection[KubernetesCollection.clusterName] ||
treeNavSelection[KubernetesCollection.clusterId]
: treeNavSelection[collectionType]}
</EuiButtonEmpty>
</>
),
@ -72,14 +76,14 @@ export const Breadcrumb = ({ treeNavSelection, onSelect }: BreadcrumbDeps) => {
]
);
if (!treeNavSelection[KubernetesCollection.cluster]) {
if (!treeNavSelection[KubernetesCollection.clusterId]) {
return null;
}
return (
<div css={styles.breadcrumb}>
{renderBreadcrumbLink(
KubernetesCollection.cluster,
KubernetesCollection.clusterId,
<EuiIcon type="heatmap" color="success" />,
!(
treeNavSelection[KubernetesCollection.namespace] ||

View file

@ -8,8 +8,15 @@ import { useInfiniteQuery } from 'react-query';
import { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { QueryDslQueryContainerBool } from '../../../types';
import { QUERY_KEY_PROCESS_EVENTS, AGGREGATE_ROUTE } from '../../../../common/constants';
import {
QUERY_KEY_PROCESS_EVENTS,
AGGREGATE_ROUTE,
MULTI_TERMS_AGGREGATE_ROUTE,
ORCHESTRATOR_CLUSTER_NAME,
} from '../../../../common/constants';
import { AggregateBucketPaginationResult } from '../../../../common/types/aggregate';
import { Bucket } from '../../../../common/types/multi_terms_aggregate';
import { KUBERNETES_COLLECTION_FIELDS } from '../helpers';
export const useFetchDynamicTreeView = (
query: QueryDslQueryContainerBool,
@ -22,8 +29,36 @@ export const useFetchDynamicTreeView = (
return useInfiniteQuery<AggregateBucketPaginationResult>(
cachingKeys,
async ({ pageParam = 0 }) =>
await http.get<any>(AGGREGATE_ROUTE, {
async ({ pageParam = 0 }) => {
if (groupBy === KUBERNETES_COLLECTION_FIELDS.clusterId) {
const { buckets } = await http.get<any>(MULTI_TERMS_AGGREGATE_ROUTE, {
query: {
query: JSON.stringify(query),
groupBys: JSON.stringify([
{
field: groupBy,
},
{
field: ORCHESTRATOR_CLUSTER_NAME,
missing: '',
},
]),
page: pageParam,
perPage: 50,
index,
},
});
return {
buckets: buckets.map((bucket: Bucket) => ({
...bucket,
key_as_string: bucket.key[1],
key: bucket.key[0],
})),
};
}
return await http.get<any>(AGGREGATE_ROUTE, {
query: {
query: JSON.stringify(query),
groupBy,
@ -31,7 +66,8 @@ export const useFetchDynamicTreeView = (
perPage: 50,
index,
},
}),
});
},
{
enabled,
getNextPageParam: (lastPage, pages) => (lastPage.hasNextPage ? pages.length : undefined),

View file

@ -15,6 +15,7 @@ import {
keys,
EuiLoadingSpinner,
} from '@elastic/eui';
import { KubernetesCollection } from '../../../types';
import {
TREE_NAVIGATION_LOADING,
TREE_NAVIGATION_SHOW_MORE,
@ -101,7 +102,12 @@ export const DynamicTreeView = ({
useEffect(() => {
if (!hasSelection && !depth && data && data.pages?.[0].buckets?.[0]?.key) {
onSelect({}, data.pages[0].buckets[0].key, tree[depth].type);
onSelect(
{},
tree[depth].type,
data.pages[0].buckets[0].key,
data.pages[0].buckets[0].key_as_string
);
}
}, [data, depth, hasSelection, onSelect, tree]);
@ -221,18 +227,26 @@ const DynamicTreeViewItem = ({
const styles = useStyles(depth);
const buttonRef = useRef<Record<string, any>>({});
const handleSelect = () => {
if (tree[depth].type === KubernetesCollection.clusterId) {
onSelect(selectionDepth, tree[depth].type, aggData.key, aggData.key_as_string);
} else {
onSelect(selectionDepth, tree[depth].type, aggData.key);
}
};
const onKeyboardToggle = () => {
if (!isLastNode) {
onToggleExpand();
}
onSelect(selectionDepth, aggData.key, tree[depth].type);
handleSelect();
};
const onButtonToggle = () => {
if (!isLastNode && !isExpanded) {
onToggleExpand();
}
onSelect(selectionDepth, aggData.key, tree[depth].type);
handleSelect();
};
const onArrowToggle = (event: MouseEvent<SVGElement>) => {
@ -309,7 +323,7 @@ const DynamicTreeViewItem = ({
/>
)}
<EuiIcon {...tree[depth].iconProps} css={styles.labelIcon} />
<span className="euiTreeView__nodeLabel">{aggData.key}</span>
<span className="euiTreeView__nodeLabel">{aggData.key_as_string || aggData.key}</span>
</button>
<div
onKeyDown={(event: React.KeyboardEvent) => onChildrenKeydown(event, aggData.key.toString())}
@ -322,6 +336,9 @@ const DynamicTreeViewItem = ({
selectionDepth={{
...selectionDepth,
[tree[depth].type]: aggData.key,
...(tree[depth].type === KubernetesCollection.clusterId && {
[KubernetesCollection.clusterName]: aggData.key_as_string,
}),
}}
tree={tree}
onSelect={onSelect}

View file

@ -12,7 +12,12 @@ export type DynamicTreeViewProps = {
depth?: number;
selectionDepth?: TreeNavSelection;
query: QueryDslQueryContainerBool;
onSelect: (selectionDepth: TreeNavSelection, key: string | number, type: string) => void;
onSelect: (
selectionDepth: TreeNavSelection,
type: string,
key: string | number,
clusterName?: string
) => void;
hasSelection?: boolean;
'aria-label': string;
selected?: string;

View file

@ -5,15 +5,24 @@
* 2.0.
*/
import { DEFAULT_QUERY } from '../../../common/constants';
import {
CLOUD_INSTANCE_NAME,
CONTAINER_IMAGE_NAME,
DEFAULT_QUERY,
ORCHESTRATOR_CLUSTER_ID,
ORCHESTRATOR_CLUSTER_NAME,
ORCHESTRATOR_NAMESPACE,
ORCHESTRATOR_RESOURCE_ID,
} from '../../../common/constants';
import { KubernetesCollection, QueryDslQueryContainerBool, TreeNavSelection } from '../../types';
export const KUBERNETES_COLLECTION_FIELDS = {
[KubernetesCollection.cluster]: 'orchestrator.cluster.name',
[KubernetesCollection.namespace]: 'orchestrator.namespace',
[KubernetesCollection.node]: 'cloud.instance.name',
[KubernetesCollection.pod]: 'orchestrator.resource.name',
[KubernetesCollection.containerImage]: 'container.image.name',
[KubernetesCollection.clusterId]: ORCHESTRATOR_CLUSTER_ID,
[KubernetesCollection.clusterName]: ORCHESTRATOR_CLUSTER_NAME,
[KubernetesCollection.namespace]: ORCHESTRATOR_NAMESPACE,
[KubernetesCollection.node]: CLOUD_INSTANCE_NAME,
[KubernetesCollection.pod]: ORCHESTRATOR_RESOURCE_ID,
[KubernetesCollection.containerImage]: CONTAINER_IMAGE_NAME,
};
export const addTreeNavSelectionToFilterQuery = (
@ -28,20 +37,22 @@ export const addTreeNavSelectionToFilterQuery = (
throw new Error('Invalid filter query');
}
parsedFilterQuery.bool.filter.push(
...Object.keys(treeNavSelection).map((collectionKey) => {
const collection = collectionKey as KubernetesCollection;
return {
bool: {
should: [
{
match: {
[KUBERNETES_COLLECTION_FIELDS[collection]]: treeNavSelection[collection],
...Object.keys(treeNavSelection)
.filter((key) => key !== KubernetesCollection.clusterName)
.map((collectionKey) => {
const collection = collectionKey as KubernetesCollection;
return {
bool: {
should: [
{
match: {
[KUBERNETES_COLLECTION_FIELDS[collection]]: treeNavSelection[collection],
},
},
},
],
},
};
})
],
},
};
})
);
validFilterQuery = JSON.stringify(parsedFilterQuery);
} catch {

View file

@ -48,7 +48,7 @@ export const useTreeView = ({ globalFilter, indexPattern }: UseTreeViewProps) =>
}, [filterQueryWithTimeRange]);
useEffect(() => {
if (!!treeNavSelection[KubernetesCollection.cluster]) {
if (!!treeNavSelection[KubernetesCollection.clusterId]) {
setHasSelection(true);
setTreeNavSelection(treeNavSelection);
}

View file

@ -11,9 +11,9 @@ import { translations } from './translations';
const LOGICAL_TREE_VIEW: DynamicTree[] = [
{
key: KUBERNETES_COLLECTION_FIELDS.cluster,
key: KUBERNETES_COLLECTION_FIELDS.clusterId,
iconProps: { type: 'heatmap', color: 'success' },
type: KubernetesCollection.cluster,
type: KubernetesCollection.clusterId,
name: translations.cluster(),
namePlural: translations.cluster(true),
},

View file

@ -15,6 +15,7 @@ import {
EuiFlexItem,
EuiToolTip,
} from '@elastic/eui';
import { KubernetesCollection } from '../../../types';
import {
TREE_VIEW_INFRASTRUCTURE_VIEW,
TREE_VIEW_LOGICAL_VIEW,
@ -117,10 +118,13 @@ export const TreeNav = () => {
tree={tree}
aria-label={selectedLabel}
selected={selected}
onSelect={(selectionDepth, key, type) => {
onSelect={(selectionDepth, type, key, clusterName) => {
const newSelectionDepth = {
...selectionDepth,
[type]: key,
...(clusterName && {
[KubernetesCollection.clusterName]: clusterName,
}),
};
setSelected(
Object.entries(newSelectionDepth)

View file

@ -48,7 +48,8 @@ export type QueryDslQueryContainerBool = {
};
export enum KubernetesCollection {
cluster = 'cluster',
clusterId = 'clusterId',
clusterName = 'clusterName',
namespace = 'namespace',
node = 'node',
pod = 'pod',
@ -56,7 +57,8 @@ export enum KubernetesCollection {
}
export interface TreeNavSelection {
[KubernetesCollection.cluster]?: string;
[KubernetesCollection.clusterId]?: string;
[KubernetesCollection.clusterName]?: string;
[KubernetesCollection.namespace]?: string;
[KubernetesCollection.node]?: string;
[KubernetesCollection.pod]?: string;

View file

@ -8,8 +8,10 @@ import { IRouter } from '@kbn/core/server';
import { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import { registerAggregateRoute } from './aggregate';
import { registerCountRoute } from './count';
import { registerMultiTermsAggregateRoute } from './multi_terms_aggregate';
export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => {
registerAggregateRoute(router);
registerCountRoute(router);
registerMultiTermsAggregateRoute(router);
};

View file

@ -0,0 +1,111 @@
/*
* 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 { schema } from '@kbn/config-schema';
import type { ElasticsearchClient } from '@kbn/core/server';
import { IRouter } from '@kbn/core/server';
import { PROCESS_EVENTS_INDEX } from '@kbn/session-view-plugin/common/constants';
import { MULTI_TERMS_AGGREGATE_ROUTE, AGGREGATE_PAGE_SIZE } from '../../common/constants';
import {
MultiTermsAggregateGroupBy,
MultiTermsAggregateBucketPaginationResult,
} from '../../common/types/multi_terms_aggregate';
export const registerMultiTermsAggregateRoute = (router: IRouter) => {
router.get(
{
path: MULTI_TERMS_AGGREGATE_ROUTE,
validate: {
query: schema.object({
query: schema.string(),
countBy: schema.maybe(schema.string()),
groupBys: schema.arrayOf(
schema.object({
field: schema.string(),
missing: schema.maybe(schema.string()),
}),
{ defaultValue: [] }
),
page: schema.number(),
perPage: schema.maybe(schema.number()),
index: schema.maybe(schema.string()),
}),
},
},
async (context, request, response) => {
const client = (await context.core).elasticsearch.client.asCurrentUser;
const { query, countBy, groupBys, page, perPage, index } = request.query;
try {
const body = await doSearch(client, query, groupBys, page, perPage, index, countBy);
return response.ok({ body });
} catch (err) {
return response.badRequest(err.message);
}
}
);
};
export const doSearch = async (
client: ElasticsearchClient,
query: string,
groupBys: MultiTermsAggregateGroupBy[],
page: number, // zero based
perPage = AGGREGATE_PAGE_SIZE,
index?: string,
countBy?: string
): Promise<MultiTermsAggregateBucketPaginationResult> => {
const queryDSL = JSON.parse(query);
const countByAggs = countBy
? {
count_by_aggs: {
cardinality: {
field: countBy,
},
},
}
: undefined;
const search = await client.search({
index: [index || PROCESS_EVENTS_INDEX],
body: {
query: queryDSL,
size: 0,
aggs: {
custom_agg: {
multi_terms: {
terms: groupBys,
},
aggs: {
...countByAggs,
bucket_sort: {
bucket_sort: {
size: perPage + 1, // check if there's a "next page"
from: perPage * page,
},
},
},
},
},
},
});
const agg: any = search.aggregations?.custom_agg;
const buckets = agg?.buckets || [];
const hasNextPage = buckets.length > perPage;
if (hasNextPage) {
buckets.pop();
}
return {
buckets,
hasNextPage,
};
};

View file

@ -212,3 +212,17 @@
}
}
}
{
"type": "doc",
"value": {
"index": "kubernetes-test-index",
"id": "15",
"source": {
"@timestamp": "2020-12-16T15:16:31.570Z",
"message": "hello world security",
"orchestrator.namespace": "namespace09",
"process.entry_leader.entity_id": "1"
}
}
}

View file

@ -14,5 +14,6 @@ export default function kubernetesSecurityApiIntegrationTests({
describe('Kubernetes security API (basic)', function () {
loadTestFile(require.resolve('./aggregate'));
loadTestFile(require.resolve('./count'));
loadTestFile(require.resolve('./multi_terms_aggregate'));
});
}

View file

@ -0,0 +1,200 @@
/*
* 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 expect from '@kbn/expect';
import { MULTI_TERMS_AGGREGATE_ROUTE } from '@kbn/kubernetes-security-plugin/common/constants';
import { FtrProviderContext } from '../../common/ftr_provider_context';
const MOCK_INDEX = 'kubernetes-test-index';
const ORCHESTRATOR_NAMESPACE_PROPERTY = 'orchestrator.namespace';
const CONTAINER_IMAGE_NAME_PROPERTY = 'container.image.name';
const ENTRY_LEADER_ENTITY_ID = 'process.entry_leader.entity_id';
const TIMESTAMP_PROPERTY = '@timestamp';
// eslint-disable-next-line import/no-default-export
export default function aggregateTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const namespaces = new Set([
'namespace',
'namespace02',
'namespace03',
'namespace04',
'namespace05',
'namespace06',
'namespace07',
'namespace08',
'namespace09',
'namespace10',
]);
describe('Kubernetes security with a basic license', () => {
before(async () => {
await esArchiver.load(
'x-pack/test/functional/es_archives/kubernetes_security/process_events'
);
});
after(async () => {
await esArchiver.unload(
'x-pack/test/functional/es_archives/kubernetes_security/process_events'
);
});
it(`${MULTI_TERMS_AGGREGATE_ROUTE} returns aggregates on process events`, async () => {
const response = await supertest
.get(MULTI_TERMS_AGGREGATE_ROUTE)
.set('kbn-xsrf', 'foo')
.query({
query: JSON.stringify({ match: { [ENTRY_LEADER_ENTITY_ID]: '1' } }),
groupBys: JSON.stringify([
{
field: ORCHESTRATOR_NAMESPACE_PROPERTY,
},
{
field: CONTAINER_IMAGE_NAME_PROPERTY,
},
]),
page: 0,
index: MOCK_INDEX,
perPage: 10,
});
expect(response.status).to.be(200);
expect(response.body.buckets.length).to.be(10);
response.body.buckets.forEach((bucket: { key: [string, string] }) => {
expect(namespaces.has(bucket.key[0])).to.be(true);
expect(bucket.key[1]).to.be('debian11');
});
});
it(`${MULTI_TERMS_AGGREGATE_ROUTE} allows pagination`, async () => {
const response = await supertest
.get(MULTI_TERMS_AGGREGATE_ROUTE)
.set('kbn-xsrf', 'foo')
.query({
query: JSON.stringify({ match: { [ENTRY_LEADER_ENTITY_ID]: '1' } }),
groupBys: JSON.stringify([
{
field: ORCHESTRATOR_NAMESPACE_PROPERTY,
},
{
field: CONTAINER_IMAGE_NAME_PROPERTY,
missing: 'default',
},
]),
page: 1,
index: MOCK_INDEX,
perPage: 5,
});
expect(response.status).to.be(200);
expect(response.body.buckets.length).to.be(5);
expect(response.body.buckets[0].key[0]).to.be('namespace06');
expect(response.body.buckets[0].key[1]).to.be('debian11');
});
it(`${MULTI_TERMS_AGGREGATE_ROUTE} allows missing field`, async () => {
const response = await supertest
.get(MULTI_TERMS_AGGREGATE_ROUTE)
.set('kbn-xsrf', 'foo')
.query({
query: JSON.stringify({ match: { [ENTRY_LEADER_ENTITY_ID]: '1' } }),
groupBys: JSON.stringify([
{
field: ORCHESTRATOR_NAMESPACE_PROPERTY,
},
{
field: CONTAINER_IMAGE_NAME_PROPERTY,
missing: 'default',
},
]),
page: 2,
index: MOCK_INDEX,
perPage: 4,
});
expect(response.status).to.be(200);
expect(response.body.buckets.length).to.be(2);
expect(response.body.buckets[1].key[0]).to.be('namespace09');
expect(response.body.buckets[1].key[1]).to.be('default');
});
it(`${MULTI_TERMS_AGGREGATE_ROUTE} return countBy value for each aggregation`, async () => {
const response = await supertest
.get(MULTI_TERMS_AGGREGATE_ROUTE)
.set('kbn-xsrf', 'foo')
.query({
query: JSON.stringify({ match: { [ENTRY_LEADER_ENTITY_ID]: '1' } }),
groupBys: JSON.stringify([
{
field: ORCHESTRATOR_NAMESPACE_PROPERTY,
},
{
field: CONTAINER_IMAGE_NAME_PROPERTY,
},
]),
countBy: ORCHESTRATOR_NAMESPACE_PROPERTY,
page: 0,
index: MOCK_INDEX,
});
expect(response.status).to.be(200);
expect(response.body.buckets.length).to.be(10);
// when groupBy and countBy use the same field, count_by_aggs.value will always be 1
response.body.buckets.forEach((agg: any) => {
expect(agg.count_by_aggs.value).to.be(1);
});
});
it(`${MULTI_TERMS_AGGREGATE_ROUTE} allows a range query`, async () => {
const response = await supertest
.get(MULTI_TERMS_AGGREGATE_ROUTE)
.set('kbn-xsrf', 'foo')
.query({
query: JSON.stringify({
range: {
[TIMESTAMP_PROPERTY]: {
gte: '2020-12-16T15:16:28.570Z',
lte: '2020-12-16T15:16:30.570Z',
},
},
}),
groupBys: JSON.stringify([
{
field: ORCHESTRATOR_NAMESPACE_PROPERTY,
},
{
field: CONTAINER_IMAGE_NAME_PROPERTY,
},
]),
page: 0,
index: MOCK_INDEX,
});
expect(response.status).to.be(200);
expect(response.body.buckets.length).to.be(3);
});
it(`${MULTI_TERMS_AGGREGATE_ROUTE} handles a bad request`, async () => {
const response = await supertest
.get(MULTI_TERMS_AGGREGATE_ROUTE)
.set('kbn-xsrf', 'foo')
.query({
query: 'asdf',
groupBys: JSON.stringify([
{
field: ORCHESTRATOR_NAMESPACE_PROPERTY,
},
{
field: CONTAINER_IMAGE_NAME_PROPERTY,
},
]),
page: 0,
index: MOCK_INDEX,
});
expect(response.status).to.be(400);
});
});
}