Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import { DataAccessLayer } from '../types';

/**
* Provides the dataAccessLayer to Resolver. This is used in production via the `Resolver` component or in tests to provide a fake data access layer.
*
* We can't provide a data access layer statically because it needs a reference to kibana context. Therefore we allow `null`. Code needs to check for a valid `DataAccessLayer` before using it.
**/
export const DataAccessLayerContext = React.createContext<DataAccessLayer | null>(null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public';
import { StartServices } from '../../types';
import { DataAccessLayer } from '../types';
import {
ResolverRelatedEvents,
ResolverTree,
ResolverEntityIndex,
} from '../../../common/endpoint/types';
import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../common/constants';

/**
* The only concrete DataAccessLayer. This isn't built in to Resolver. Instead we inject it. This way, tests can provide a fake one.
*/
export function dataAccessLayerFactory(
context: KibanaReactContextValue<StartServices>
): DataAccessLayer {
const dataAccessLayer: DataAccessLayer = {
async relatedEvents(entityID: string): Promise<ResolverRelatedEvents> {
return context.services.http.get(`/api/endpoint/resolver/${entityID}/events`, {
query: { events: 100 },
});
},
async resolverTree(entityID: string, signal: AbortSignal): Promise<ResolverTree> {
return context.services.http.get(`/api/endpoint/resolver/${entityID}`, {
signal,
});
},

indexPatterns(): string[] {
return context.services.uiSettings.get(defaultIndexKey);
},

async entities({
_id,
indices,
signal,
}: {
_id: string;
indices: string[];
signal: AbortSignal;
}): Promise<ResolverEntityIndex> {
return context.services.http.get('/api/endpoint/resolver/entity', {
signal,
query: {
_id,
indices,
},
});
},
};
return dataAccessLayer;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { DataAccessLayer } from '../types';
import {
ResolverRelatedEvents,
ResolverTree,
ResolverEntityIndex,
} from '../../../common/endpoint/types';
import { mockEndpointEvent } from '../store/mocks/endpoint_event';
import { mockTreeWithNoAncestorsAnd2Children } from '../store/mocks/resolver_tree';

/**
* Simplest mock dataAccessLayer possible.
*/
export function mockDataAccessLayer(): DataAccessLayer {
const originID = 'a';
return {
/**
* Fetch related events for an entity ID
*/
relatedEvents(entityID: string): Promise<ResolverRelatedEvents> {
return Promise.resolve({
entityID,
events: [
mockEndpointEvent({
entityID,
name: 'event',
timestamp: 0,
}),
],
nextEvent: null,
});
},

/**
* Fetch a ResolverTree for a entityID
*/
resolverTree(): Promise<ResolverTree> {
return Promise.resolve(
mockTreeWithNoAncestorsAnd2Children({
originID,
firstChildID: 'b',
secondChildID: 'c',
})
);
},

/**
* Get an array of index patterns that contain events.
*/
indexPatterns(): string[] {
return ['index pattern'];
},

/**
* Get entities matching a document.
*/
entities(): Promise<ResolverEntityIndex> {
return Promise.resolve([{ entity_id: 'a' }]);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,20 @@

import { createStore, applyMiddleware, Store } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public';
import { ResolverState } from '../types';
import { StartServices } from '../../types';
import { ResolverState, DataAccessLayer } from '../types';
import { resolverReducer } from './reducer';
import { resolverMiddlewareFactory } from './middleware';
import { ResolverAction } from './actions';

export const storeFactory = (
context?: KibanaReactContextValue<StartServices>
dataAccessLayer: DataAccessLayer
): Store<ResolverState, ResolverAction> => {
const actionsDenylist: Array<ResolverAction['type']> = ['userMovedPointer'];
const composeEnhancers = composeWithDevTools({
name: 'Resolver',
actionsBlacklist: actionsDenylist,
});
const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(context));
const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(dataAccessLayer));

return createStore(resolverReducer, composeEnhancers(middlewareEnhancer));
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
*/

import { Dispatch, MiddlewareAPI } from 'redux';
import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public';
import { StartServices } from '../../../types';
import { ResolverState } from '../../types';
import { ResolverState, DataAccessLayer } from '../../types';
import { ResolverRelatedEvents } from '../../../../common/endpoint/types';
import { ResolverTreeFetcher } from './resolver_tree_fetcher';
import { ResolverAction } from '../actions';

type MiddlewareFactory<S = ResolverState> = (
context?: KibanaReactContextValue<StartServices>
dataAccessLayer: DataAccessLayer
) => (
api: MiddlewareAPI<Dispatch<ResolverAction>, S>
) => (next: Dispatch<ResolverAction>) => (action: ResolverAction) => unknown;
Expand All @@ -24,15 +22,9 @@ type MiddlewareFactory<S = ResolverState> = (
* For actions that the app triggers directly, use `app` as a prefix for the type.
* For actions that are triggered as a result of server interaction, use `server` as a prefix for the type.
*/
export const resolverMiddlewareFactory: MiddlewareFactory = (context) => {
export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: DataAccessLayer) => {
return (api) => (next) => {
// This cannot work w/o `context`.
if (!context) {
return async (action: ResolverAction) => {
next(action);
};
}
const resolverTreeFetcher = ResolverTreeFetcher(context, api);
const resolverTreeFetcher = ResolverTreeFetcher(dataAccessLayer, api);
return async (action: ResolverAction) => {
next(action);

Expand All @@ -45,12 +37,7 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => {
const entityIdToFetchFor = action.payload;
let result: ResolverRelatedEvents | undefined;
try {
result = await context.services.http.get(
`/api/endpoint/resolver/${entityIdToFetchFor}/events`,
{
query: { events: 100 },
}
);
result = await dataAccessLayer.relatedEvents(entityIdToFetchFor);
} catch {
api.dispatch({
type: 'serverFailedToReturnRelatedEventData',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@
import { Dispatch, MiddlewareAPI } from 'redux';
import { ResolverTree, ResolverEntityIndex } from '../../../../common/endpoint/types';

import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public';
import { ResolverState } from '../../types';
import { ResolverState, DataAccessLayer } from '../../types';
import * as selectors from '../selectors';
import { StartServices } from '../../../types';
import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../../common/constants';
import { ResolverAction } from '../actions';
/**
* A function that handles syncing ResolverTree data w/ the current entity ID.
Expand All @@ -23,7 +20,7 @@ import { ResolverAction } from '../actions';
* This is a factory because it is stateful and keeps that state in closure.
*/
export function ResolverTreeFetcher(
context: KibanaReactContextValue<StartServices>,
dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
): () => void {
let lastRequestAbortController: AbortController | undefined;
Expand All @@ -48,17 +45,12 @@ export function ResolverTreeFetcher(
payload: databaseDocumentIDToFetch,
});
try {
const indices: string[] = context.services.uiSettings.get(defaultIndexKey);
const matchingEntities: ResolverEntityIndex = await context.services.http.get(
'/api/endpoint/resolver/entity',
{
signal: lastRequestAbortController.signal,
query: {
_id: databaseDocumentIDToFetch,
indices,
},
}
);
const indices: string[] = dataAccessLayer.indexPatterns();
const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({
_id: databaseDocumentIDToFetch,
indices,
signal: lastRequestAbortController.signal,
});
if (matchingEntities.length < 1) {
// If no entity_id could be found for the _id, bail out with a failure.
api.dispatch({
Expand All @@ -68,9 +60,10 @@ export function ResolverTreeFetcher(
return;
}
const entityIDToFetch = matchingEntities[0].entity_id;
result = await context.services.http.get(`/api/endpoint/resolver/${entityIDToFetch}`, {
signal: lastRequestAbortController.signal,
});
result = await dataAccessLayer.resolverTree(
entityIDToFetch,
lastRequestAbortController.signal
);
} catch (error) {
// https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError
if (error instanceof DOMException && error.name === 'AbortError') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function mockEndpointEvent({
}: {
entityID: string;
name: string;
parentEntityId: string | undefined;
parentEntityId?: string;
timestamp: number;
}): EndpointEvent {
return {
Expand Down
42 changes: 41 additions & 1 deletion x-pack/plugins/security_solution/public/resolver/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import { Store } from 'redux';
import { BBox } from 'rbush';
import { ResolverAction } from './store/actions';
import { ResolverEvent, ResolverRelatedEvents, ResolverTree } from '../../common/endpoint/types';
import {
ResolverEvent,
ResolverRelatedEvents,
ResolverTree,
ResolverEntityIndex,
} from '../../common/endpoint/types';

/**
* Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`.
Expand Down Expand Up @@ -436,3 +441,38 @@ export interface IsometricTaxiLayout {
*/
ariaLevels: Map<ResolverEvent, number>;
}

/**
* An object with methods that can be used to access data from the Kibana server.
* This is injected into Resolver.
* This allows tests to provide a mock data access layer.
* In the future, other implementations of Resolver could provide different data access layers.
*/
export interface DataAccessLayer {
/**
* Fetch related events for an entity ID
*/
relatedEvents: (entityID: string) => Promise<ResolverRelatedEvents>;

/**
* Fetch a ResolverTree for a entityID
*/
resolverTree: (entityID: string, signal: AbortSignal) => Promise<ResolverTree>;

/**
* Get an array of index patterns that contain events.
*/
indexPatterns: () => string[];

/**
* Get entities matching a document.
*/
entities: (parameters: {
/** _id of the document to find an entity in. */
_id: string;
/** indices to search in */
indices: string[];
/** signal to abort the request */
signal: AbortSignal;
}) => Promise<ResolverEntityIndex>;
}
Loading