Skip to content

Commit

Permalink
[EEM] Add instanceAsFilter helper (elastic#204319)
Browse files Browse the repository at this point in the history
## Summary

This PR adds a new function on the entityManager's plugin start
contract, `instanceAsFilter`, which translates an entity instance into a
KQL filter by doing a look up of the sources for the entity's type.

The usage is as simple as this:
```
const kql = entityManager.v2.instanceAsFilter(entityInstance, clusterClient, logger);
```

This can be used as a filter in a query request or as the input for a
[Filters
aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filters-aggregation.html)

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
miltonhultgren and kibanamachine authored Dec 17, 2024
1 parent d5e0d3a commit 3d8ac49
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* 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 { EntityV2 } from '@kbn/entities-schema';
import { instanceAsFilter } from './instance_as_filter';
import { readSourceDefinitions } from './source_definition';
import { loggerMock } from '@kbn/logging-mocks';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { EntitySourceDefinition } from '../types';
import { UnknownEntityType } from '../errors/unknown_entity_type';
import { InvalidEntityInstance } from '../errors/invalid_entity_instance';

const readSourceDefinitionsMock = readSourceDefinitions as jest.Mock;
jest.mock('./source_definition', () => ({
readSourceDefinitions: jest.fn(),
}));
const esClientMock = elasticsearchServiceMock.createClusterClient();
const logger = loggerMock.create();

describe('instanceAsFilter', () => {
it('throws if no sources are found for the type', async () => {
const instance: EntityV2 = {
'entity.type': 'my_type',
'entity.id': 'whatever',
'entity.display_name': 'Whatever',
};

const sources: EntitySourceDefinition[] = [];
readSourceDefinitionsMock.mockResolvedValue(sources);

await expect(instanceAsFilter(instance, esClientMock, logger)).rejects.toThrowError(
UnknownEntityType
);
});

it('throws if the instance cannot match any sources due to missing identity fields', async () => {
const instance: EntityV2 = {
'entity.type': 'my_type',
'entity.id': 'whatever',
'entity.display_name': 'Whatever',
};

const sources: EntitySourceDefinition[] = [
{
id: 'my_source',
type_id: 'my_type',
identity_fields: ['host.name'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
];
readSourceDefinitionsMock.mockResolvedValue(sources);

await expect(instanceAsFilter(instance, esClientMock, logger)).rejects.toThrowError(
InvalidEntityInstance
);
});

it('creates a single source filter for a single identity field', async () => {
const instance: EntityV2 = {
'entity.type': 'my_type',
'entity.id': 'whatever',
'entity.display_name': 'Whatever',
'host.name': 'my_host',
};

const sources: EntitySourceDefinition[] = [
{
id: 'my_source',
type_id: 'my_type',
identity_fields: ['host.name'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
];
readSourceDefinitionsMock.mockResolvedValue(sources);

await expect(instanceAsFilter(instance, esClientMock, logger)).resolves.toBe(
'(host.name: "my_host")'
);
});

it('creates a single source filter for multiple identity field', async () => {
const instance: EntityV2 = {
'entity.type': 'my_type',
'entity.id': 'whatever',
'entity.display_name': 'Whatever',
'host.name': 'my_host',
'host.os': 'my_os',
};

const sources: EntitySourceDefinition[] = [
{
id: 'my_source',
type_id: 'my_type',
identity_fields: ['host.name', 'host.os'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
];
readSourceDefinitionsMock.mockResolvedValue(sources);

await expect(instanceAsFilter(instance, esClientMock, logger)).resolves.toBe(
'(host.name: "my_host" AND host.os: "my_os")'
);
});

it('creates multiple source filters for a single identity field', async () => {
const instance: EntityV2 = {
'entity.type': 'my_type',
'entity.id': 'whatever',
'entity.display_name': 'Whatever',
'host.name': 'my_host',
'host.os': 'my_os',
};

const sources: EntitySourceDefinition[] = [
{
id: 'my_source_host',
type_id: 'my_type',
identity_fields: ['host.name'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
{
id: 'my_source_os',
type_id: 'my_type',
identity_fields: ['host.os'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
];
readSourceDefinitionsMock.mockResolvedValue(sources);

await expect(instanceAsFilter(instance, esClientMock, logger)).resolves.toBe(
'(host.name: "my_host") OR (host.os: "my_os")'
);
});

it('creates multiple source filters for multiple identity field', async () => {
const instance: EntityV2 = {
'entity.type': 'my_type',
'entity.id': 'whatever',
'entity.display_name': 'Whatever',
'host.name': 'my_host',
'host.os': 'my_os',
'host.arch': 'my_arch',
};

const sources: EntitySourceDefinition[] = [
{
id: 'my_source_host',
type_id: 'my_type',
identity_fields: ['host.name', 'host.arch'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
{
id: 'my_source_os',
type_id: 'my_type',
identity_fields: ['host.os', 'host.arch'],
index_patterns: [],
metadata_fields: [],
filters: [],
},
];
readSourceDefinitionsMock.mockResolvedValue(sources);

await expect(instanceAsFilter(instance, esClientMock, logger)).resolves.toBe(
'(host.name: "my_host" AND host.arch: "my_arch") OR (host.os: "my_os" AND host.arch: "my_arch")'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 { EntityV2 } from '@kbn/entities-schema';
import { Logger } from '@kbn/core/server';
import { compact } from 'lodash';
import { readSourceDefinitions } from './source_definition';
import { InternalClusterClient } from '../types';
import { UnknownEntityType } from '../errors/unknown_entity_type';
import { InvalidEntityInstance } from '../errors/invalid_entity_instance';

export async function instanceAsFilter(
instance: EntityV2,
clusterClient: InternalClusterClient,
logger: Logger
) {
const sources = await readSourceDefinitions(clusterClient, logger, {
type: instance['entity.type'],
});

if (sources.length === 0) {
throw new UnknownEntityType(`No sources found for type ${instance['entity.type']}`);
}

const sourceFilters = compact<string>(
sources.map((source) => {
const { identity_fields: identityFields } = source;

const instanceHasRequiredFields = identityFields.every((identityField) =>
instance[identityField] ? true : false
);

if (!instanceHasRequiredFields) {
return undefined;
}

const fieldFilters = identityFields.map(
(identityField) => `${identityField}: "${instance[identityField]}"`
);

return `(${fieldFilters.join(' AND ')})`;
})
);

if (sourceFilters.length === 0) {
throw new InvalidEntityInstance(
`Entity ${instance['entity.id']} of type ${instance['entity.type']} is missing some identity fields, no sources could match`
);
}

return sourceFilters.join(' OR ');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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 class InvalidEntityInstance extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidEntityInstance';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ import {
READ_ENTITIES_PRIVILEGE,
} from './lib/v2/constants';
import { installBuiltInDefinitions } from './lib/v2/definitions/install_built_in_definitions';
import { instanceAsFilter } from './lib/v2/definitions/instance_as_filter';

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EntityManagerServerPluginSetup {}
export interface EntityManagerServerPluginStart {
getScopedClient: (options: { request: KibanaRequest }) => Promise<EntityClient>;
v2: {
instanceAsFilter: typeof instanceAsFilter;
};
}

export const config: PluginConfigDescriptor<EntityManagerConfig> = {
Expand Down Expand Up @@ -197,6 +201,9 @@ export class EntityManagerServerPlugin
getScopedClient: async ({ request }: { request: KibanaRequest }) => {
return this.getScopedClient({ request, coreStart: core });
},
v2: {
instanceAsFilter,
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@
"@kbn/es-types",
"@kbn/apm-utils",
"@kbn/features-plugin",
"@kbn/core-elasticsearch-server-mocks",
]
}

0 comments on commit 3d8ac49

Please sign in to comment.