[RAC] Rule monitoring: Event Log for Rule Registry (#98353) (#100794)

**Needed for:** rule execution log for Security https://github.com/elastic/kibana/pull/94143
**Related to:**

- alerts-as-data: https://github.com/elastic/kibana/issues/93728, https://github.com/elastic/kibana/issues/93729, https://github.com/elastic/kibana/issues/93730
- RFC for index naming https://github.com/elastic/kibana/issues/98912

## Summary

This PR adds a mechanism for writing to / reading from / bootstrapping indices for RAC project into the `rule_registry` plugin. Particularly, indices for alerts-as-data and rule execution events. This implementation is similar to existing implementations like `event_log` plugin (see https://github.com/elastic/kibana/pull/98353#issuecomment-833045980 for historical perspective), but we're going to converge all of them into 1 or 2 implementations. At least we should have a single one in `rule_registry` itself.

In this PR I tried to incorporate most of the feedback received in the RFC (https://github.com/elastic/kibana/issues/98912), but if you notice I missed/forgot something, please let me know in the comments.

Done in this PR:

- [x] Schema-agnostic APIs for working with Elasticsearch.
- [x] Schema-aware log definition and bootstrapping API (creating hierarchical logs).
- [x] Schema-aware write API (logging events).
- [x] Schema-aware read API (searching logs, filtering, sorting, pagination, aggregation).
- [x] Support for Kibana spaces, space-aware index bootstrapping (either at rule creation or rule execution time).

As for reviewing this PR, perhaps it might be easier to start with:

- checking description of https://github.com/elastic/kibana/issues/98912
- checking usage examples https://github.com/elastic/kibana/pull/98353/files#diff-c049ff2198cc69bd50a69e92d29e88da7e10b9a152bdaceaf3d41826e712c12b
- checking public api https://github.com/elastic/kibana/pull/98353/files#diff-8e9ef0dbcbc60b1861d492a03865b2ae76a56ec38ada61898c991d3a74bd6268

## Next steps

Next steps towards rule execution log in Security (https://github.com/elastic/kibana/pull/94143):

- define actual schema for rule execution events
- inject instance of rule execution log into Security rule executors and route handlers
- implement actual execution logging in rule executors
- update route handlers to start fetching execution events and metrics from the log instead of custom saved objects

Next steps in the context of RAC and unified implementation:

- converge this implementation with `RuleDataService` implementation
  - implement robust index bootstrapping
  - reconsider using FieldMap as a generic type parameter
  - implement validation for documents being indexed
- cover the final implementation with tests
- write comprehensive docs: update plugin README, add JSDoc comments to all public interfaces
This commit is contained in:
Georgii Gorbachev 2021-05-27 20:33:45 +03:00 committed by GitHub
parent 3f455e8ef6
commit d3fbb91a22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1810 additions and 41 deletions

View file

@ -130,19 +130,20 @@ export class APMPlugin
registerFeaturesUsage({ licensingPlugin: plugins.licensing });
const { ruleDataService } = plugins.ruleRegistry;
const getCoreStart = () =>
core.getStartServices().then(([coreStart]) => coreStart);
const ready = once(async () => {
const componentTemplateName = plugins.ruleRegistry.getFullAssetName(
const componentTemplateName = ruleDataService.getFullAssetName(
'apm-mappings'
);
if (!plugins.ruleRegistry.isWriteEnabled()) {
if (!ruleDataService.isWriteEnabled()) {
return;
}
await plugins.ruleRegistry.createOrUpdateComponentTemplate({
await ruleDataService.createOrUpdateComponentTemplate({
name: componentTemplateName,
body: {
template: {
@ -167,16 +168,14 @@ export class APMPlugin
},
});
await plugins.ruleRegistry.createOrUpdateIndexTemplate({
name: plugins.ruleRegistry.getFullAssetName('apm-index-template'),
await ruleDataService.createOrUpdateIndexTemplate({
name: ruleDataService.getFullAssetName('apm-index-template'),
body: {
index_patterns: [
plugins.ruleRegistry.getFullAssetName('observability-apm*'),
ruleDataService.getFullAssetName('observability-apm*'),
],
composed_of: [
plugins.ruleRegistry.getFullAssetName(
TECHNICAL_COMPONENT_TEMPLATE_NAME
),
ruleDataService.getFullAssetName(TECHNICAL_COMPONENT_TEMPLATE_NAME),
componentTemplateName,
],
},
@ -188,7 +187,7 @@ export class APMPlugin
});
const ruleDataClient = new RuleDataClient({
alias: plugins.ruleRegistry.getFullAssetName('observability-apm'),
alias: ruleDataService.getFullAssetName('observability-apm'),
getClusterClient: async () => {
const coreStart = await getCoreStart();
return coreStart.elasticsearch.client.asInternalUser;

View file

@ -57,7 +57,7 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
return coreStart.elasticsearch.client.asInternalUser;
},
ready: () => Promise.resolve(),
alias: plugins.ruleRegistry.getFullAssetName(),
alias: plugins.ruleRegistry.ruleDataService.getFullAssetName(),
});
registerRoutes({

View file

@ -8,6 +8,8 @@
],
"requiredPlugins": [
"alerting",
"data",
"spaces",
"triggersActionsUi"
],
"server": true

View file

@ -0,0 +1,20 @@
/*
* 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, TypeOf } from '@kbn/config-schema';
export const config = {
schema: schema.object({
enabled: schema.boolean({ defaultValue: true }),
write: schema.object({
enabled: schema.boolean({ defaultValue: true }),
}),
index: schema.string({ defaultValue: '.alerts' }),
}),
};
export type RuleRegistryPluginConfig = TypeOf<typeof config.schema>;

View file

@ -0,0 +1,14 @@
/*
* 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 * from './index_bootstrapper';
export * from './index_management_gateway';
export * from './index_reader';
export * from './index_writer';
export * from './resources/ilm_policy';
export * from './resources/index_mappings';
export * from './resources/index_names';

View file

@ -0,0 +1,107 @@
/*
* 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 { PublicMethodsOf } from '@kbn/utility-types';
import { Logger } from 'src/core/server';
import { IndexNames } from './resources/index_names';
import { IndexMappings } from './resources/index_mappings';
import { createIndexTemplate } from './resources/index_template';
import { IlmPolicy, defaultIlmPolicy } from './resources/ilm_policy';
import { IIndexManagementGateway } from './index_management_gateway';
interface ConstructorParams {
gateway: IIndexManagementGateway;
logger: Logger;
}
export interface IndexSpecification {
indexNames: IndexNames;
indexMappings: IndexMappings;
ilmPolicy?: IlmPolicy;
}
export type IIndexBootstrapper = PublicMethodsOf<IndexBootstrapper>;
// TODO: Converge with the logic of .siem-signals index bootstrapping
// x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts
// TODO: Handle race conditions and potential errors between multiple instances of Kibana
// trying to bootstrap the same index. Possible options:
// - robust idempotent logic with error handling
// - leveraging task_manager to make sure bootstrapping is run only once at a time
// - using some sort of distributed lock
// Maybe we can check how Saved Objects service bootstraps .kibana index
export class IndexBootstrapper {
private readonly gateway: IIndexManagementGateway;
private readonly logger: Logger;
constructor(params: ConstructorParams) {
this.gateway = params.gateway;
this.logger = params.logger.get('IndexBootstrapper');
}
public async run(indexSpec: IndexSpecification): Promise<boolean> {
this.logger.debug('bootstrapping elasticsearch resources starting');
try {
const { indexNames, indexMappings, ilmPolicy } = indexSpec;
await this.createIlmPolicyIfNotExists(indexNames, ilmPolicy);
await this.createIndexTemplateIfNotExists(indexNames, indexMappings);
await this.createInitialIndexIfNotExists(indexNames);
} catch (err) {
this.logger.error(`error bootstrapping elasticsearch resources: ${err.message}`);
return false;
}
this.logger.debug('bootstrapping elasticsearch resources complete');
return true;
}
private async createIlmPolicyIfNotExists(names: IndexNames, policy?: IlmPolicy): Promise<void> {
const { indexIlmPolicyName } = names;
const exists = await this.gateway.doesIlmPolicyExist(indexIlmPolicyName);
if (!exists) {
const ilmPolicy = policy ?? defaultIlmPolicy;
await this.gateway.createIlmPolicy(indexIlmPolicyName, ilmPolicy);
}
}
private async createIndexTemplateIfNotExists(
names: IndexNames,
mappings: IndexMappings
): Promise<void> {
const { indexTemplateName } = names;
const templateVersion = 1; // TODO: get from EventSchema definition
const template = createIndexTemplate(names, mappings, templateVersion);
const exists = await this.gateway.doesIndexTemplateExist(indexTemplateName);
if (!exists) {
await this.gateway.createIndexTemplate(indexTemplateName, template);
} else {
await this.gateway.updateIndexTemplate(indexTemplateName, template);
}
}
private async createInitialIndexIfNotExists(names: IndexNames): Promise<void> {
const { indexAliasName, indexInitialName } = names;
const exists = await this.gateway.doesAliasExist(indexAliasName);
if (!exists) {
await this.gateway.createIndex(indexInitialName, {
aliases: {
[indexAliasName]: {
is_write_index: true,
},
},
});
}
}
}

View file

@ -0,0 +1,139 @@
/*
* 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 { PublicMethodsOf } from '@kbn/utility-types';
import { ElasticsearchClient, Logger } from 'src/core/server';
import { IlmPolicy } from './resources/ilm_policy';
import { IndexTemplate } from './resources/index_template';
interface ConstructorParams {
elasticsearch: Promise<ElasticsearchClient>;
logger: Logger;
}
export type IIndexManagementGateway = PublicMethodsOf<IndexManagementGateway>;
export class IndexManagementGateway {
private readonly elasticsearch: Promise<ElasticsearchClient>;
private readonly logger: Logger;
constructor(params: ConstructorParams) {
this.elasticsearch = params.elasticsearch;
this.logger = params.logger.get('IndexManagementGateway');
}
public async doesIlmPolicyExist(policyName: string): Promise<boolean> {
this.logger.debug(`Checking if ILM policy exists; name="${policyName}"`);
try {
const es = await this.elasticsearch;
await es.transport.request({
method: 'GET',
path: `/_ilm/policy/${policyName}`,
});
} catch (e) {
if (e.statusCode === 404) return false;
throw new Error(`Error checking existence of ILM policy: ${e.message}`);
}
return true;
}
public async createIlmPolicy(policyName: string, policy: IlmPolicy): Promise<void> {
this.logger.debug(`Creating ILM policy; name="${policyName}"`);
try {
const es = await this.elasticsearch;
await es.transport.request({
method: 'PUT',
path: `/_ilm/policy/${policyName}`,
body: policy,
});
} catch (e) {
throw new Error(`Error creating ILM policy: ${e.message}`);
}
}
public async doesIndexTemplateExist(templateName: string): Promise<boolean> {
this.logger.debug(`Checking if index template exists; name="${templateName}"`);
try {
const es = await this.elasticsearch;
const { body } = await es.indices.existsTemplate({ name: templateName });
return body as boolean;
} catch (e) {
throw new Error(`Error checking existence of index template: ${e.message}`);
}
}
public async createIndexTemplate(templateName: string, template: IndexTemplate): Promise<void> {
this.logger.debug(`Creating index template; name="${templateName}"`);
try {
const es = await this.elasticsearch;
await es.indices.putTemplate({ create: true, name: templateName, body: template });
} catch (e) {
// The error message doesn't have a type attribute we can look to guarantee it's due
// to the template already existing (only long message) so we'll check ourselves to see
// if the template now exists. This scenario would happen if you startup multiple Kibana
// instances at the same time.
const existsNow = await this.doesIndexTemplateExist(templateName);
if (!existsNow) {
const error = new Error(`Error creating index template: ${e.message}`);
Object.assign(error, { wrapped: e });
throw error;
}
}
}
public async updateIndexTemplate(templateName: string, template: IndexTemplate): Promise<void> {
this.logger.debug(`Updating index template; name="${templateName}"`);
try {
const { settings, ...templateWithoutSettings } = template;
const es = await this.elasticsearch;
await es.indices.putTemplate({
create: false,
name: templateName,
body: templateWithoutSettings,
});
} catch (e) {
throw new Error(`Error updating index template: ${e.message}`);
}
}
public async doesAliasExist(aliasName: string): Promise<boolean> {
this.logger.debug(`Checking if index alias exists; name="${aliasName}"`);
try {
const es = await this.elasticsearch;
const { body } = await es.indices.existsAlias({ name: aliasName });
return body as boolean;
} catch (e) {
throw new Error(`Error checking existence of initial index: ${e.message}`);
}
}
public async createIndex(indexName: string, body: Record<string, unknown> = {}): Promise<void> {
this.logger.debug(`Creating index; name="${indexName}"`);
this.logger.debug(JSON.stringify(body, null, 2));
try {
const es = await this.elasticsearch;
await es.indices.create({
index: indexName,
body,
});
} catch (e) {
if (e.body?.error?.type !== 'resource_already_exists_exception') {
this.logger.error(e);
this.logger.error(JSON.stringify(e.meta, null, 2));
throw new Error(`Error creating initial index: ${e.message}`);
}
}
}
}

View file

@ -0,0 +1,44 @@
/*
* 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 { PublicMethodsOf } from '@kbn/utility-types';
import { estypes } from '@elastic/elasticsearch';
import { Logger, ElasticsearchClient } from 'src/core/server';
interface ConstructorParams {
indexName: string;
elasticsearch: Promise<ElasticsearchClient>;
logger: Logger;
}
export type IIndexReader = PublicMethodsOf<IndexReader>;
export class IndexReader {
private readonly indexName: string;
private readonly elasticsearch: Promise<ElasticsearchClient>;
private readonly logger: Logger;
constructor(params: ConstructorParams) {
this.indexName = params.indexName;
this.elasticsearch = params.elasticsearch;
this.logger = params.logger.get('IndexReader');
}
public async search<TDocument>(request: estypes.SearchRequest) {
const requestToSend: estypes.SearchRequest = {
...request,
index: this.indexName,
};
this.logger.debug(`Searching; request: ${JSON.stringify(requestToSend, null)}`);
const esClient = await this.elasticsearch;
const response = await esClient.search<TDocument>(requestToSend);
return response;
}
}

View file

@ -0,0 +1,94 @@
/*
* 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 { PublicMethodsOf } from '@kbn/utility-types';
import util from 'util';
import { Logger, ElasticsearchClient } from 'src/core/server';
import { BufferedStream } from './utils/buffered_stream';
type Document = Record<string, unknown>;
interface BufferItem {
index: string;
doc: Document;
}
interface ConstructorParams {
indexName: string;
elasticsearch: Promise<ElasticsearchClient>;
isWriteEnabled: boolean;
logger: Logger;
}
export type IIndexWriter = PublicMethodsOf<IndexWriter>;
export class IndexWriter {
private readonly indexName: string;
private readonly elasticsearch: Promise<ElasticsearchClient>;
private readonly isWriteEnabled: boolean;
private readonly logger: Logger;
private readonly buffer: BufferedStream<BufferItem>;
constructor(params: ConstructorParams) {
this.indexName = params.indexName;
this.elasticsearch = params.elasticsearch;
this.isWriteEnabled = params.isWriteEnabled;
this.logger = params.logger.get('IndexWriter');
this.buffer = new BufferedStream<BufferItem>({
flush: (items) => this.bulkIndex(items),
});
}
public indexOne(doc: Document): void {
if (this.isWriteEnabled) {
this.logger.debug('Buffering 1 document');
this.buffer.enqueue({ index: this.indexName, doc });
}
}
public indexMany(docs: Document[]): void {
if (this.isWriteEnabled) {
this.logger.debug(`Buffering ${docs.length} documents`);
docs.forEach((doc) => {
this.buffer.enqueue({ index: this.indexName, doc });
});
}
}
public async close(): Promise<void> {
await this.buffer.closeAndWaitUntilFlushed();
}
private async bulkIndex(items: BufferItem[]): Promise<void> {
this.logger.debug(`Indexing ${items.length} documents`);
const bulkBody: Array<Record<string, unknown>> = [];
for (const item of items) {
if (item.doc === undefined) continue;
bulkBody.push({ create: { _index: item.index } });
bulkBody.push(item.doc);
}
try {
const es = await this.elasticsearch;
const response = await es.bulk({ body: bulkBody });
if (response.body.errors) {
const error = new Error('Error writing some bulk events');
error.stack += '\n' + util.inspect(response.body.items, { depth: null });
this.logger.error(error);
}
} catch (e) {
this.logger.error(
`error writing bulk events: "${e.message}"; docs: ${JSON.stringify(bulkBody)}`
);
}
}
}

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.
*/
import { estypes } from '@elastic/elasticsearch';
export interface IlmPolicy {
policy: estypes.Policy;
}
export const defaultIlmPolicy: IlmPolicy = {
policy: {
phases: {
hot: {
min_age: '0ms',
actions: {
rollover: {
max_age: '90d',
max_size: '50gb',
},
},
},
delete: {
actions: {
delete: {},
},
},
},
},
};

View file

@ -0,0 +1,12 @@
/*
* 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 IndexMappings {
dynamic: 'strict' | boolean;
properties: Record<string, { type: string } | IndexMappings>;
_meta?: Record<string, unknown>;
}

View file

@ -0,0 +1,84 @@
/*
* 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 IndexParams {
/** @example '.alerts' */
indexPrefix: string;
/** @example 'security', 'security.alerts', 'observability.events' */
logName: string;
/** @example 'default' */
kibanaSpaceId: string;
}
export interface IndexNames extends IndexParams {
/** @example '.alerts-security.alerts' */
indexBaseName: string;
/** @example '.alerts-security.alerts-*' */
indexBasePattern: string;
/** @example '.alerts-security.alerts-default' */
indexAliasName: string;
/** @example '.alerts-security.alerts-default-*' */
indexAliasPattern: string;
/** @example '.alerts-security.alerts-default-policy' */
indexIlmPolicyName: string;
/** @example '.alerts-security.alerts-default-template' */
indexTemplateName: string;
/** @example '.alerts-security.alerts-default-000001' */
indexInitialName: string;
}
export abstract class IndexNames {
public static create(params: IndexParams): IndexNames {
const { indexPrefix, logName, kibanaSpaceId } = params;
// TODO: validate params
const indexBaseName = joinWithDash(indexPrefix, logName);
const indexBasePattern = joinWithDash(indexPrefix, logName, '*');
const indexAliasName = joinWithDash(indexPrefix, logName, kibanaSpaceId);
const indexAliasPattern = joinWithDash(indexPrefix, logName, kibanaSpaceId, '*');
const indexIlmPolicyName = joinWithDash(indexPrefix, logName, kibanaSpaceId, 'policy');
const indexTemplateName = joinWithDash(indexPrefix, logName, kibanaSpaceId, 'template');
const indexInitialName = joinWithDash(indexPrefix, logName, kibanaSpaceId, '000001');
return {
indexPrefix,
logName,
kibanaSpaceId,
indexBaseName,
indexBasePattern,
indexAliasName,
indexAliasPattern,
indexIlmPolicyName,
indexTemplateName,
indexInitialName,
};
}
public static createChild(parent: IndexNames, logName: string): IndexNames {
return this.create({
indexPrefix: parent.indexPrefix,
logName: this.createChildLogName(parent.logName, logName),
kibanaSpaceId: parent.kibanaSpaceId,
});
}
public static createChildLogName(parentLogName: string, logName: string): string {
return joinWithDot(parentLogName, logName);
}
}
const joinWithDash = (...names: string[]): string => names.join('-');
const joinWithDot = (...names: string[]): string => names.join('.');

View file

@ -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 { estypes } from '@elastic/elasticsearch';
import { IndexNames } from './index_names';
import { IndexMappings } from './index_mappings';
export type IndexTemplate = estypes.PutIndexTemplateRequest['body'];
export const createIndexTemplate = (
names: IndexNames,
mappings: IndexMappings,
version: number
): IndexTemplate => {
const { indexAliasName, indexAliasPattern, indexIlmPolicyName } = names;
return {
index_patterns: [indexAliasPattern],
settings: {
number_of_shards: 1, // TODO: do we need to set this?
auto_expand_replicas: '0-1', // TODO: do we need to set?
index: {
lifecycle: {
name: indexIlmPolicyName,
rollover_alias: indexAliasName,
},
},
mapping: {
total_fields: {
limit: 10000,
},
},
sort: {
field: '@timestamp',
order: 'desc',
},
},
mappings: {
...mappings,
_meta: {
...mappings._meta,
version,
},
},
version,
};
};

View file

@ -0,0 +1,52 @@
/*
* 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 { Subject } from 'rxjs';
import { bufferTime, filter as rxFilter, switchMap } from 'rxjs/operators';
export const DEFAULT_BUFFER_TIME_MS = 1000;
export const DEFAULT_BUFFER_SIZE = 100;
interface ConstructorParams<TItem> {
maxBufferTimeMs?: number;
maxBufferSize?: number;
flush: (items: TItem[]) => Promise<void>;
}
// TODO: handle possible exceptions in flush and maybe add retry logic
export class BufferedStream<TItem> {
private readonly buffer$: Subject<TItem>;
private readonly whenBufferCompleteAndFlushed: Promise<void>;
constructor(params: ConstructorParams<TItem>) {
const maxBufferTime = params.maxBufferTimeMs ?? DEFAULT_BUFFER_TIME_MS;
const maxBufferSize = params.maxBufferSize ?? DEFAULT_BUFFER_SIZE;
this.buffer$ = new Subject<TItem>();
// Buffer items for time/buffer length, ignore empty buffers,
// then flush the buffered items; kick things off with a promise
// on the observable, which we'll wait on in shutdown
this.whenBufferCompleteAndFlushed = this.buffer$
.pipe(
bufferTime(maxBufferTime, null, maxBufferSize),
rxFilter((docs) => docs.length > 0),
switchMap(async (docs) => await params.flush(docs))
)
.toPromise();
}
public enqueue(item: TItem): void {
this.buffer$.next(item);
}
public async closeAndWaitUntilFlushed(): Promise<void> {
this.buffer$.complete();
await this.whenBufferCompleteAndFlushed;
}
}

View file

@ -0,0 +1,9 @@
/*
* 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 * from './schema_types';
export * from './schema';

View file

@ -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 { EventSchema, Event } from './schema_types';
import { FieldMap, runtimeTypeFromFieldMap, mergeFieldMaps } from '../../../common/field_map';
import {
TechnicalRuleFieldMaps,
technicalRuleFieldMap,
} from '../../../common/assets/field_maps/technical_rule_field_map';
const baseSchema = createSchema(technicalRuleFieldMap);
export abstract class Schema {
public static create<TMap extends FieldMap>(fields: TMap): EventSchema<TMap> {
return createSchema(fields);
}
public static combine<T1 extends FieldMap, T2 extends FieldMap>(
s1: EventSchema<T1>,
s2: EventSchema<T2>
): EventSchema<T1 & T2> {
const combinedFields = mergeFieldMaps(s1.objectFields, s2.objectFields);
return createSchema(combinedFields);
}
public static getBase(): EventSchema<TechnicalRuleFieldMaps> {
return baseSchema;
}
public static extendBase<TMap extends FieldMap>(
fields: TMap
): EventSchema<TechnicalRuleFieldMaps & TMap> {
const extensionSchema = createSchema(fields);
return this.combine(baseSchema, extensionSchema);
}
}
function createSchema<TMap extends FieldMap>(fields: TMap): EventSchema<TMap> {
const objectType: Event<TMap> = ({} as unknown) as Event<TMap>;
const runtimeType = runtimeTypeFromFieldMap(fields);
return {
objectFields: fields,
objectType,
runtimeType,
};
}

View file

@ -0,0 +1,20 @@
/*
* 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 { FieldMap, FieldMapType, TypeOfFieldMap } from '../../../common/field_map';
export interface EventSchema<TMap extends FieldMap> {
objectFields: TMap;
objectType: Event<TMap>;
runtimeType: EventRuntimeType<TMap>;
}
export type Event<TMap extends FieldMap> = TypeOfFieldMap<TMap>;
export type EventRuntimeType<TMap extends FieldMap> = FieldMapType<TMap>;
export { FieldMap };

View file

@ -0,0 +1,10 @@
/*
* 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 * from './elasticsearch';
export * from './event_schema';
export * from './log';

View file

@ -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 { estypes } from '@elastic/elasticsearch';
import { DeepPartial } from '../utils/utility_types';
import { IndexNames } from '../elasticsearch';
import { IEventLog, IEventLogger, IEventLoggerTemplate, IEventQueryBuilder } from './public_api';
import { EventLogParams } from './internal_api';
import { EventLoggerTemplate } from './event_logger_template';
import { EventQueryBuilder } from './event_query_builder';
export class EventLog<TEvent> implements IEventLog<TEvent> {
private readonly params: EventLogParams;
private readonly initialTemplate: IEventLoggerTemplate<TEvent>;
constructor(params: EventLogParams) {
this.params = params;
this.initialTemplate = new EventLoggerTemplate<TEvent>({
...params,
eventLoggerName: '',
eventFields: {},
});
}
public getNames(): IndexNames {
return this.params.indexNames;
}
public getLoggerTemplate(fields: DeepPartial<TEvent>): IEventLoggerTemplate<TEvent> {
return this.initialTemplate.getLoggerTemplate(fields);
}
public getLogger(loggerName: string, fields?: DeepPartial<TEvent>): IEventLogger<TEvent> {
return this.initialTemplate.getLogger(loggerName, fields);
}
public getQueryBuilder(): IEventQueryBuilder<TEvent> {
return new EventQueryBuilder<TEvent>(this.params);
}
public async search<TDocument = TEvent>(
request: estypes.SearchRequest
): Promise<estypes.SearchResponse<TDocument>> {
const response = await this.params.indexReader.search<TDocument>(request);
return response.body;
}
}

View file

@ -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 { Logger } from 'kibana/server';
import { IIndexBootstrapper, IndexSpecification } from '../elasticsearch';
interface ConstructorParams {
indexSpec: IndexSpecification;
indexBootstrapper: IIndexBootstrapper;
isWriteEnabled: boolean;
logger: Logger;
}
export class EventLogBootstrapper {
private readonly indexSpec: IndexSpecification;
private readonly indexBootstrapper: IIndexBootstrapper;
private readonly logger: Logger;
private readonly isWriteEnabled: boolean;
private isIndexBootstrapped: boolean;
constructor(params: ConstructorParams) {
this.indexSpec = params.indexSpec;
this.indexBootstrapper = params.indexBootstrapper;
this.logger = params.logger.get('EventLogBootstrapper');
this.isWriteEnabled = params.isWriteEnabled;
this.isIndexBootstrapped = false;
}
public async run(): Promise<void> {
if (this.isIndexBootstrapped || !this.isWriteEnabled) {
return;
}
const { logName, indexAliasName } = this.indexSpec.indexNames;
const logInfo = `log="${logName}" index="${indexAliasName}"`;
this.logger.debug(`Bootstrapping started, ${logInfo}`);
this.isIndexBootstrapped = await this.indexBootstrapper.run(this.indexSpec);
this.logger.debug(
`Bootstrapping ${this.isIndexBootstrapped ? 'succeeded' : 'failed'}, ${logInfo}`
);
if (!this.isIndexBootstrapped) {
throw new Error(`Event log bootstrapping failed, ${logInfo}`);
}
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 { IlmPolicy, defaultIlmPolicy, IndexNames } from '../elasticsearch';
import { EventSchema, FieldMap, Schema } from '../event_schema';
import { EventLogOptions, IEventLogDefinition } from './public_api';
export class EventLogDefinition<TMap extends FieldMap> implements IEventLogDefinition<TMap> {
public readonly eventLogName: string;
public readonly eventSchema: EventSchema<TMap>;
public readonly ilmPolicy: IlmPolicy;
constructor(options: EventLogOptions<TMap>) {
// TODO: validate options; options.name should not contain "-" and "."
this.eventLogName = options.name;
this.eventSchema = options.schema;
this.ilmPolicy = options.ilmPolicy ?? defaultIlmPolicy;
}
public defineChild<TExtMap extends FieldMap = TMap>(
options: EventLogOptions<TExtMap>
): IEventLogDefinition<TMap & TExtMap> {
const childName = IndexNames.createChildLogName(this.eventLogName, options.name);
const childSchema = Schema.combine(this.eventSchema, options.schema);
const childPolicy = options.ilmPolicy ?? this.ilmPolicy;
return new EventLogDefinition({
name: childName,
schema: childSchema,
ilmPolicy: childPolicy,
});
}
}

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.
*/
import { IIndexWriter } from '../elasticsearch';
import { IEventLog } from './public_api';
import { IEventLogProvider } from './internal_api';
import { EventLogBootstrapper } from './event_log_bootstrapper';
interface ConstructorParams<TEvent> {
log: IEventLog<TEvent>;
logBootstrapper: EventLogBootstrapper;
indexWriter: IIndexWriter;
}
export class EventLogProvider<TEvent> implements IEventLogProvider<TEvent> {
constructor(private readonly params: ConstructorParams<TEvent>) {}
public getLog(): IEventLog<TEvent> {
return this.params.log;
}
public async bootstrapLog(): Promise<void> {
await this.params.logBootstrapper.run();
}
public async shutdownLog(): Promise<void> {
await this.params.indexWriter.close();
}
}

View file

@ -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 { Event, FieldMap } from '../event_schema';
import { IEventLogDefinition } from './public_api';
import { IEventLogRegistry, IEventLogProvider } from './internal_api';
const getRegistryKey = (definition: IEventLogDefinition<any>, spaceId: string) =>
`${definition.eventLogName}-${spaceId}`;
interface RegistryEntry {
definition: IEventLogDefinition<any>;
spaceId: string;
provider: IEventLogProvider<any>;
}
export class EventLogRegistry implements IEventLogRegistry {
private readonly map = new Map<string, RegistryEntry>();
public get<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
spaceId: string
): IEventLogProvider<Event<TMap>> | null {
const key = getRegistryKey(definition, spaceId);
const entry = this.map.get(key);
return entry != null ? (entry.provider as IEventLogProvider<Event<TMap>>) : null;
}
public add<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
spaceId: string,
provider: IEventLogProvider<Event<TMap>>
): void {
const key = getRegistryKey(definition, spaceId);
if (this.map.has(key)) {
throw new Error(`Event log already registered, key="${key}"`);
}
this.map.set(key, {
definition,
spaceId,
provider,
});
}
public async shutdown(): Promise<void> {
const entries = Array.from(this.map.values());
const promises = entries.map(({ provider }) => provider.shutdownLog());
await Promise.all(promises);
}
}

View file

@ -0,0 +1,162 @@
/*
* 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 {
IndexBootstrapper,
IndexManagementGateway,
IndexNames,
IndexReader,
IndexSpecification,
IndexWriter,
} from '../elasticsearch';
import { Event, FieldMap } from '../event_schema';
import { IEventLogRegistry, IEventLogProvider } from './internal_api';
import {
EventLogServiceConfig,
EventLogServiceDependencies,
IEventLog,
IEventLogDefinition,
IEventLogResolver,
} from './public_api';
import { EventLog } from './event_log';
import { EventLogBootstrapper } from './event_log_bootstrapper';
import { EventLogProvider } from './event_log_provider';
import { mappingFromFieldMap } from './utils/mapping_from_field_map';
export class EventLogResolver implements IEventLogResolver {
private readonly indexBootstrapper: IndexBootstrapper;
constructor(
private readonly config: EventLogServiceConfig,
private readonly deps: EventLogServiceDependencies,
private readonly registry: IEventLogRegistry,
private readonly bootstrapLog: boolean
) {
this.indexBootstrapper = this.createIndexBootstrapper();
}
public async resolve<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
kibanaSpaceId: string
): Promise<IEventLog<Event<TMap>>> {
const provider = this.resolveLogProvider(definition, kibanaSpaceId);
if (this.bootstrapLog) {
await provider.bootstrapLog();
}
return provider.getLog();
}
private resolveLogProvider<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
kibanaSpaceId: string
): IEventLogProvider<Event<TMap>> {
const existingProvider = this.registry.get(definition, kibanaSpaceId);
if (existingProvider) {
return existingProvider;
}
const indexSpec = this.createIndexSpec(definition, kibanaSpaceId);
const indexReader = this.createIndexReader(indexSpec);
const indexWriter = this.createIndexWriter(indexSpec);
const logBootstrapper = this.createEventLogBootstrapper(indexSpec);
const log = this.createEventLog<TMap>(indexSpec, indexReader, indexWriter);
const logProvider = new EventLogProvider({
log,
logBootstrapper,
indexWriter,
});
this.registry.add(definition, kibanaSpaceId, logProvider);
return logProvider;
}
private createIndexSpec<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
kibanaSpaceId: string
): IndexSpecification {
const { indexPrefix } = this.config;
const { eventLogName, eventSchema, ilmPolicy } = definition;
const indexNames = IndexNames.create({
indexPrefix,
logName: eventLogName,
kibanaSpaceId,
});
const indexMappings = mappingFromFieldMap(eventSchema.objectFields);
return { indexNames, indexMappings, ilmPolicy };
}
private createIndexBootstrapper(): IndexBootstrapper {
const { clusterClient, logger } = this.deps;
return new IndexBootstrapper({
gateway: new IndexManagementGateway({
elasticsearch: clusterClient.then((c) => c.asInternalUser),
logger,
}),
logger,
});
}
private createIndexReader(indexSpec: IndexSpecification): IndexReader {
const { clusterClient, logger } = this.deps;
const { indexNames } = indexSpec;
return new IndexReader({
indexName: indexNames.indexAliasPattern,
elasticsearch: clusterClient.then((c) => c.asInternalUser), // TODO: internal or current?
logger,
});
}
private createIndexWriter(indexSpec: IndexSpecification): IndexWriter {
const { clusterClient, logger } = this.deps;
const { isWriteEnabled } = this.config;
const { indexNames } = indexSpec;
return new IndexWriter({
indexName: indexNames.indexAliasName,
elasticsearch: clusterClient.then((c) => c.asInternalUser), // TODO: internal or current?
isWriteEnabled,
logger,
});
}
private createEventLogBootstrapper(indexSpec: IndexSpecification): EventLogBootstrapper {
const { logger } = this.deps;
const { isWriteEnabled } = this.config;
return new EventLogBootstrapper({
indexSpec,
indexBootstrapper: this.indexBootstrapper,
isWriteEnabled,
logger,
});
}
private createEventLog<TMap extends FieldMap>(
indexSpec: IndexSpecification,
indexReader: IndexReader,
indexWriter: IndexWriter
): IEventLog<Event<TMap>> {
const { logger } = this.deps;
return new EventLog<Event<TMap>>({
indexNames: indexSpec.indexNames,
indexReader,
indexWriter,
logger,
});
}
}

View file

@ -0,0 +1,67 @@
/*
* 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 { KibanaRequest } from 'kibana/server';
import { Event, FieldMap } from '../event_schema';
import {
EventLogServiceConfig,
EventLogServiceDependencies,
IEventLog,
IEventLogDefinition,
IEventLogResolver,
IEventLogService,
IScopedEventLogResolver,
} from './public_api';
import { EventLogRegistry } from './event_log_registry';
import { EventLogResolver } from './event_log_resolver';
const BOOTSTRAP_BY_DEFAULT = true;
interface ConstructorParams {
config: EventLogServiceConfig;
dependencies: EventLogServiceDependencies;
}
export class EventLogService implements IEventLogService {
private readonly registry: EventLogRegistry;
constructor(private readonly params: ConstructorParams) {
this.registry = new EventLogRegistry();
}
public getResolver(bootstrapLog = BOOTSTRAP_BY_DEFAULT): IEventLogResolver {
const { params, registry } = this;
const { config, dependencies } = params;
return new EventLogResolver(config, dependencies, registry, bootstrapLog);
}
public getScopedResolver(
request: KibanaRequest,
bootstrapLog = BOOTSTRAP_BY_DEFAULT
): IScopedEventLogResolver {
const resolver = this.getResolver(bootstrapLog);
return {
resolve: async <TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>
): Promise<IEventLog<Event<TMap>>> => {
const spaces = await this.params.dependencies.spacesService;
const spaceId = spaces.getSpaceId(request);
const log = await resolver.resolve(definition, spaceId);
return log;
},
};
}
public async stop(): Promise<void> {
await this.registry.shutdown();
}
}

View file

@ -0,0 +1,36 @@
/*
* 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 { DeepPartial } from '../utils/utility_types';
import { mergeFields } from '../utils/fields';
import { EventLoggerParams } from './internal_api';
import { IEventLogger, IEventLoggerTemplate } from './public_api';
export class EventLogger<TEvent> implements IEventLogger<TEvent> {
private readonly params: EventLoggerParams<TEvent>;
private readonly ownTemplate: IEventLoggerTemplate<TEvent>;
constructor(params: EventLoggerParams<TEvent>, template: IEventLoggerTemplate<TEvent>) {
this.params = params;
this.ownTemplate = template;
}
public getLoggerTemplate(fields: DeepPartial<TEvent>): IEventLoggerTemplate<TEvent> {
return this.ownTemplate.getLoggerTemplate(fields);
}
public getLogger(name: string, fields?: DeepPartial<TEvent>): IEventLogger<TEvent> {
return this.ownTemplate.getLogger(name, fields);
}
public logEvent(fields: DeepPartial<TEvent>): void {
const { eventFields, indexWriter } = this.params;
const event = mergeFields(eventFields, fields);
indexWriter.indexOne(event);
}
}

View file

@ -0,0 +1,55 @@
/*
* 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 { DeepPartial } from '../utils/utility_types';
import { mergeFields } from '../utils/fields';
import { IEventLogger, IEventLoggerTemplate } from './public_api';
import { EventLoggerParams } from './internal_api';
import { EventLogger } from './event_logger';
export class EventLoggerTemplate<TEvent> implements IEventLoggerTemplate<TEvent> {
private readonly params: EventLoggerParams<TEvent>;
constructor(params: EventLoggerParams<TEvent>) {
this.params = params;
}
public getLoggerTemplate(fields: DeepPartial<TEvent>): IEventLoggerTemplate<TEvent> {
const nextParams = this.getNextParams('', fields);
return new EventLoggerTemplate<TEvent>(nextParams);
}
public getLogger(name: string, fields?: DeepPartial<TEvent>): IEventLogger<TEvent> {
const nextParams = this.getNextParams(name, fields);
const nextTemplate = new EventLoggerTemplate<TEvent>(nextParams);
return new EventLogger<TEvent>(nextParams, nextTemplate);
}
private getNextParams(
extName: string,
extFields?: DeepPartial<TEvent>
): EventLoggerParams<TEvent> {
const { indexNames, eventLoggerName, eventFields } = this.params;
const baseName = eventLoggerName;
const nextName = [baseName, extName].filter(Boolean).join('.');
const baseFields = eventFields;
const nextFields = mergeFields(baseFields, extFields, {
// TODO: Define a schema for own fields used/set by event log. Add it to the base schema.
// Then maybe introduce a base type for TEvent.
'kibana.rac.event_log.log_name': indexNames.logName,
'kibana.rac.event_log.logger_name': nextName,
} as any);
return {
...this.params,
eventLoggerName: nextName,
eventFields: nextFields,
};
}
}

View file

@ -0,0 +1,27 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
import { IIndexReader } from '../elasticsearch';
import { truthy } from '../utils/predicates';
import { IEventQuery } from './public_api';
export interface EventQueryParams {
indexReader: IIndexReader;
request: estypes.SearchRequest;
}
export class EventQuery<TEvent> implements IEventQuery<TEvent> {
constructor(private readonly params: EventQueryParams) {}
public async execute(): Promise<TEvent[]> {
const { indexReader, request } = this.params;
const response = await indexReader.search<TEvent>(request);
return response.body.hits.hits.map((hit) => hit._source).filter(truthy);
}
}

View file

@ -0,0 +1,110 @@
/*
* 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 { getFlattenedObject } from '@kbn/std';
import { estypes } from '@elastic/elasticsearch';
import { esKuery } from '../../../../../../src/plugins/data/server';
import { DeepPartial } from '../utils/utility_types';
import { mergeFields } from '../utils/fields';
import { EventLogParams } from './internal_api';
import { IEventQueryBuilder, IEventQuery, SortingParams, PaginationParams } from './public_api';
import { EventQuery } from './event_query';
export class EventQueryBuilder<TEvent> implements IEventQueryBuilder<TEvent> {
private readonly params: EventLogParams;
private loggerName: string;
private fields: DeepPartial<TEvent> | null;
private kql: string;
private sorting: SortingParams;
private pagination: PaginationParams;
constructor(params: EventLogParams) {
this.params = params;
this.loggerName = '';
this.fields = null;
this.kql = '';
this.sorting = [{ '@timestamp': { order: 'desc' } }, { 'event.sequence': { order: 'desc' } }];
this.pagination = { page: 1, perPage: 20 };
}
public filterByLogger(loggerName: string): IEventQueryBuilder<TEvent> {
this.loggerName = loggerName;
return this;
}
public filterByFields(fields: DeepPartial<TEvent>): IEventQueryBuilder<TEvent> {
this.fields = mergeFields(this.fields ?? {}, fields);
return this;
}
public filterByKql(kql: string): IEventQueryBuilder<TEvent> {
this.kql = kql;
return this;
}
public sortBy(params: SortingParams): IEventQueryBuilder<TEvent> {
this.sorting = params;
return this;
}
public paginate(params: PaginationParams): IEventQueryBuilder<TEvent> {
this.pagination = params;
return this;
}
public buildQuery(): IEventQuery<TEvent> {
const { indexReader } = this.params;
const { page, perPage } = this.pagination;
const request: estypes.SearchRequest = {
track_total_hits: true,
body: {
from: (page - 1) * perPage,
size: perPage,
sort: this.sorting,
query: {
bool: {
filter: this.buildFilter(),
},
},
},
};
return new EventQuery<TEvent>({ indexReader, request });
}
private buildFilter(): estypes.QueryContainer[] {
const result: estypes.QueryContainer[] = [];
if (this.loggerName) {
result.push({
term: { 'kibana.rac.event_log.logger_name': this.loggerName },
});
}
if (this.fields) {
const flatFields = getFlattenedObject(this.fields);
Object.entries(flatFields)
.map(([key, value]) => {
const queryName = Array.isArray(value) ? 'terms' : 'term';
return { [queryName]: { [key]: value } };
})
.forEach((query) => {
result.push(query);
});
}
if (this.kql) {
const dsl = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(this.kql));
const queries = Array.isArray(dsl) ? dsl : [dsl];
result.push(...queries);
}
return result;
}
}

View file

@ -0,0 +1,10 @@
/*
* 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 * from './event_log_definition';
export * from './event_log_service';
export * from './public_api';

View file

@ -0,0 +1,46 @@
/*
* 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 { Logger } from 'kibana/server';
import { IIndexReader, IIndexWriter, IndexNames } from '../elasticsearch';
import { Event, FieldMap } from '../event_schema';
import { DeepPartial } from '../utils/utility_types';
import { IEventLogDefinition, IEventLog } from './public_api';
export interface IEventLogRegistry {
get<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
spaceId: string
): IEventLogProvider<Event<TMap>> | null;
add<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
spaceId: string,
provider: IEventLogProvider<Event<TMap>>
): void;
shutdown(): Promise<void>;
}
export interface IEventLogProvider<TEvent> {
getLog(): IEventLog<TEvent>;
bootstrapLog(): Promise<void>;
shutdownLog(): Promise<void>;
}
export interface EventLogParams {
indexNames: IndexNames;
indexReader: IIndexReader;
indexWriter: IIndexWriter;
logger: Logger;
}
export interface EventLoggerParams<TEvent> extends EventLogParams {
eventLoggerName: string;
eventFields: DeepPartial<TEvent>;
}

View file

@ -0,0 +1,113 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
import { IClusterClient, KibanaRequest, Logger } from 'kibana/server';
import { SpacesServiceStart } from '../../../../spaces/server';
import { IlmPolicy, IndexNames, IndexSpecification } from '../elasticsearch';
import { FieldMap, Event, EventSchema } from '../event_schema';
import { DeepPartial } from '../utils/utility_types';
export { IlmPolicy, IndexSpecification };
// -------------------------------------------------------------------------------------------------
// Definition API (defining log hierarchies as simple objects)
export interface EventLogOptions<TMap extends FieldMap> {
name: string;
schema: EventSchema<TMap>;
ilmPolicy?: IlmPolicy;
}
export interface IEventLogDefinition<TMap extends FieldMap> {
eventLogName: string;
eventSchema: EventSchema<TMap>;
ilmPolicy: IlmPolicy;
defineChild<TExtMap extends FieldMap>(
options: EventLogOptions<TExtMap>
): IEventLogDefinition<TMap & TExtMap>;
}
// -------------------------------------------------------------------------------------------------
// Resolving and bootstrapping API (creating runtime objects representing logs, bootstrapping indices)
export interface EventLogServiceConfig {
indexPrefix: string;
isWriteEnabled: boolean;
}
export interface EventLogServiceDependencies {
clusterClient: Promise<IClusterClient>;
spacesService: Promise<SpacesServiceStart>;
logger: Logger;
}
export interface IEventLogService {
getResolver(bootstrapLog?: boolean): IEventLogResolver;
getScopedResolver(request: KibanaRequest, bootstrapLog?: boolean): IScopedEventLogResolver;
}
export interface IEventLogResolver {
resolve<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>,
spaceId: string
): Promise<IEventLog<Event<TMap>>>;
}
export interface IScopedEventLogResolver {
resolve<TMap extends FieldMap>(
definition: IEventLogDefinition<TMap>
): Promise<IEventLog<Event<TMap>>>;
}
export interface IEventLog<TEvent> extends IEventLoggerTemplate<TEvent> {
getNames(): IndexNames;
getQueryBuilder(): IEventQueryBuilder<TEvent>;
search<TDocument = TEvent>(
request: estypes.SearchRequest
): Promise<estypes.SearchResponse<TDocument>>;
}
// -------------------------------------------------------------------------------------------------
// Write API (logging events)
export interface IEventLoggerTemplate<TEvent> {
getLoggerTemplate(fields: DeepPartial<TEvent>): IEventLoggerTemplate<TEvent>;
getLogger(name: string, fields?: DeepPartial<TEvent>): IEventLogger<TEvent>;
}
export interface IEventLogger<TEvent> extends IEventLoggerTemplate<TEvent> {
logEvent(fields: DeepPartial<TEvent>): void;
}
// -------------------------------------------------------------------------------------------------
// Read API (searching, filtering, sorting, pagination, aggregation over events)
export interface IEventQueryBuilder<TEvent> {
filterByLogger(loggerName: string): IEventQueryBuilder<TEvent>;
filterByFields(fields: DeepPartial<TEvent>): IEventQueryBuilder<TEvent>;
filterByKql(kql: string): IEventQueryBuilder<TEvent>;
sortBy(params: SortingParams): IEventQueryBuilder<TEvent>;
paginate(params: PaginationParams): IEventQueryBuilder<TEvent>;
buildQuery(): IEventQuery<TEvent>;
}
export type SortingParams = estypes.Sort;
export interface PaginationParams {
page: number;
perPage: number;
}
export interface IEventQuery<TEvent> {
execute(): Promise<TEvent[]>;
}

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.
*/
import { set } from '@elastic/safer-lodash-set';
import { FieldMap } from '../../../../common/field_map';
import { IndexMappings } from '../../elasticsearch';
export function mappingFromFieldMap(fieldMap: FieldMap): IndexMappings {
const mappings = {
dynamic: 'strict' as const,
properties: {},
};
const fields = Object.keys(fieldMap).map((key) => {
const field = fieldMap[key];
return {
name: key,
...field,
};
});
fields.forEach((field) => {
const { name, required, array, ...rest } = field;
set(mappings.properties, field.name.split('.').join('.properties.'), rest);
});
return mappings;
}

View file

@ -0,0 +1,18 @@
/*
* 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 { merge } from 'lodash';
import { DeepPartial } from './utility_types';
export const mergeFields = <TEvent>(
base: DeepPartial<TEvent>,
ext1?: DeepPartial<TEvent>,
ext2?: DeepPartial<TEvent>,
ext3?: DeepPartial<TEvent>
): DeepPartial<TEvent> => {
return merge({}, base, ext1 ?? {}, ext2 ?? {}, ext3 ?? {});
};

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export function nonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
export type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T; // from lodash
export function truthy<T>(value: T): value is Truthy<T> {
return Boolean(value);
}

View file

@ -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 { createReadySignal, ReadySignal } from './ready_signal';
describe('ReadySignal', () => {
let readySignal: ReadySignal<number>;
beforeEach(() => {
readySignal = createReadySignal<number>();
});
test('works as expected', async () => {
readySignal.signal(42);
const ready = await readySignal.wait();
expect(ready).toBe(42);
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 ReadySignal<T = void> {
wait(): Promise<T>;
signal(value: T): void;
}
export function createReadySignal<T>(): ReadySignal<T> {
let resolver: (value: T) => void;
const promise = new Promise<T>((resolve) => {
resolver = resolve;
});
async function wait(): Promise<T> {
return await promise;
}
function signal(value: T) {
resolver(value);
}
return { wait, signal };
}

View file

@ -0,0 +1,12 @@
/*
* 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 type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U> ? Array<DeepPartial<U>> : DeepPartial<T[P]>;
};

View file

@ -5,27 +5,15 @@
* 2.0.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginInitializerContext } from 'src/core/server';
import { RuleRegistryPlugin } from './plugin';
export * from './config';
export type { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract } from './plugin';
export { RuleDataClient } from './rule_data_client';
export { IRuleDataClient } from './rule_data_client/types';
export { getRuleExecutorData, RuleExecutorData } from './utils/get_rule_executor_data';
export { createLifecycleRuleTypeFactory } from './utils/create_lifecycle_rule_type_factory';
export const config = {
schema: schema.object({
enabled: schema.boolean({ defaultValue: true }),
write: schema.object({
enabled: schema.boolean({ defaultValue: true }),
}),
index: schema.string({ defaultValue: '.alerts' }),
}),
};
export type RuleRegistryPluginConfig = TypeOf<typeof config.schema>;
export const plugin = (initContext: PluginInitializerContext) =>
new RuleRegistryPlugin(initContext);

View file

@ -5,45 +5,99 @@
* 2.0.
*/
import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server';
import { RuleDataPluginService } from './rule_data_plugin_service';
import { RuleRegistryPluginConfig } from '.';
import { PluginInitializerContext, Plugin, CoreSetup, Logger } from 'src/core/server';
import { SpacesPluginStart } from '../../spaces/server';
import { RuleRegistryPluginConfig } from './config';
import { RuleDataPluginService } from './rule_data_plugin_service';
import { EventLogService, IEventLogService } from './event_log';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RuleRegistryPluginSetupDependencies {}
interface RuleRegistryPluginStartDependencies {
spaces: SpacesPluginStart;
}
export interface RuleRegistryPluginSetupContract {
ruleDataService: RuleDataPluginService;
eventLogService: IEventLogService;
}
export type RuleRegistryPluginSetupContract = RuleDataPluginService;
export type RuleRegistryPluginStartContract = void;
export class RuleRegistryPlugin implements Plugin<RuleRegistryPluginSetupContract> {
constructor(private readonly initContext: PluginInitializerContext) {
this.initContext = initContext;
export class RuleRegistryPlugin
implements
Plugin<
RuleRegistryPluginSetupContract,
RuleRegistryPluginStartContract,
RuleRegistryPluginSetupDependencies,
RuleRegistryPluginStartDependencies
> {
private readonly config: RuleRegistryPluginConfig;
private readonly logger: Logger;
private eventLogService: EventLogService | null;
constructor(initContext: PluginInitializerContext) {
this.config = initContext.config.get<RuleRegistryPluginConfig>();
this.logger = initContext.logger.get();
this.eventLogService = null;
}
public setup(core: CoreSetup): RuleRegistryPluginSetupContract {
const config = this.initContext.config.get<RuleRegistryPluginConfig>();
public setup(
core: CoreSetup<RuleRegistryPluginStartDependencies, RuleRegistryPluginStartContract>
): RuleRegistryPluginSetupContract {
const { config, logger } = this;
const logger = this.initContext.logger.get();
const startDependencies = core.getStartServices().then(([coreStart, pluginStart]) => {
return {
core: coreStart,
...pluginStart,
};
});
const service = new RuleDataPluginService({
const ruleDataService = new RuleDataPluginService({
logger,
isWriteEnabled: config.write.enabled,
index: config.index,
getClusterClient: async () => {
const [coreStart] = await core.getStartServices();
return coreStart.elasticsearch.client.asInternalUser;
const deps = await startDependencies;
return deps.core.elasticsearch.client.asInternalUser;
},
});
service.init().catch((originalError) => {
ruleDataService.init().catch((originalError) => {
const error = new Error('Failed installing assets');
// @ts-ignore
error.stack = originalError.stack;
logger.error(error);
});
return service;
const eventLogService = new EventLogService({
config: {
indexPrefix: this.config.index,
isWriteEnabled: this.config.write.enabled,
},
dependencies: {
clusterClient: startDependencies.then((deps) => deps.core.elasticsearch.client),
spacesService: startDependencies.then((deps) => deps.spaces.spacesService),
logger: logger.get('eventLog'),
},
});
this.eventLogService = eventLogService;
return { ruleDataService, eventLogService };
}
public start(): RuleRegistryPluginStartContract {}
public stop() {}
public stop() {
const { eventLogService, logger } = this;
if (eventLogService) {
eventLogService.stop().catch((e) => {
logger.error(e);
});
}
}
}

View file

@ -10,7 +10,9 @@
"include": ["common/**/*", "server/**/*", "public/**/*", "../../../typings/**/*"],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },
{ "path": "../alerting/tsconfig.json" },
{ "path": "../spaces/tsconfig.json" },
{ "path": "../triggers_actions_ui/tsconfig.json" }
]
}