[Security solution] [Endpoint] Use internal ES user in get index patterns and autosuggestions for event filters form (#145883)

## Summary

- Cretes new search strategy for getting index patterns in security
solution plugin that uses the internal ES user in order to retrieve
event filters fields without having extra index privileges.
- Adds new API endpoint for autocomplete suggestions that uses
unified_search plugin logic but using the internal ES user.
- Updates frontend code to use both approaches above in event filters
form.
- Adds new unit tests

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
David Sánchez 2022-12-02 09:09:50 +01:00 committed by GitHub
parent 8c7efbba54
commit 2b3755e395
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 902 additions and 153 deletions

View file

@ -7,6 +7,7 @@
*/
import moment from 'moment';
import { clone } from 'lodash';
import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server';
import { registerRoutes } from './routes';
import { ConfigSchema } from '../../config';
@ -32,6 +33,7 @@ export class AutocompleteService implements Plugin<void> {
terminateAfter: moment.duration(terminateAfter).asMilliseconds(),
timeout: moment.duration(timeout).asMilliseconds(),
}),
getInitializerContextConfig: () => clone(this.initializerContext.config),
};
}

View file

@ -6,10 +6,32 @@
* Side Public License, v 1.
*/
import moment from 'moment';
import { Observable } from 'rxjs';
import { ConfigSchema } from '../config';
import { AutocompleteSetup } from './autocomplete';
const autocompleteSetupMock: jest.Mocked<AutocompleteSetup> = {
getAutocompleteSettings: jest.fn(),
// @ts-ignore as it is partially defined because not all fields are needed
getInitializerContextConfig: jest.fn(() => ({
create: jest.fn(
() =>
new Observable<ConfigSchema>((subscribe) =>
subscribe.next({
autocomplete: {
querySuggestions: { enabled: true },
valueSuggestions: {
enabled: true,
tiers: [],
terminateAfter: moment.duration(),
timeout: moment.duration(),
},
},
})
)
),
})),
};
function createSetupContract() {

View file

@ -57,6 +57,9 @@ export const BASE_POLICY_RESPONSE_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy_respons
export const BASE_POLICY_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy`;
export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`;
/** Suggestions routes */
export const SUGGESTIONS_ROUTE = `${BASE_ENDPOINT_ROUTE}/suggestions/{suggestion_type}`;
/** Host Isolation Routes */
export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`;
export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`;

View file

@ -0,0 +1,23 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
export const EndpointSuggestionsSchema = {
body: schema.object({
field: schema.string(),
query: schema.string(),
filters: schema.maybe(schema.any()),
fieldMeta: schema.maybe(schema.any()),
}),
params: schema.object({
// Ready to be used with other suggestion types like endpoints
suggestion_type: schema.oneOf([schema.literal('eventFilters')]),
}),
};
export type EndpointSuggestionsBody = TypeOf<typeof EndpointSuggestionsSchema.body>;

View file

@ -98,7 +98,8 @@ interface FetchIndexReturn {
*/
export const useFetchIndex = (
indexNames: string[],
onlyCheckIfIndicesExist: boolean = false
onlyCheckIfIndicesExist: boolean = false,
strategy: string = 'indexFields'
): [boolean, FetchIndexReturn] => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
@ -124,7 +125,7 @@ export const useFetchIndex = (
{ indices: iNames, onlyCheckIfIndicesExist },
{
abortSignal: abortCtrl.current.signal,
strategy: 'indexFields',
strategy,
}
)
.subscribe({
@ -169,7 +170,7 @@ export const useFetchIndex = (
abortCtrl.current.abort();
asyncSearch();
},
[data.search, addError, addWarning, onlyCheckIfIndicesExist, setLoading, setState]
[data.search, addError, addWarning, onlyCheckIfIndicesExist, setLoading, setState, strategy]
);
useEffect(() => {

View file

@ -0,0 +1,24 @@
/*
* 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 { useMemo } from 'react';
import type { AutocompleteStart } from '@kbn/unified-search-plugin/public/autocomplete';
import type { ValueSuggestionsGetFn } from '@kbn/unified-search-plugin/public/autocomplete/providers/value_suggestion_provider';
/**
* Hook to get a memoized suggestions interface
*/
export function useSuggestions(fn: ValueSuggestionsGetFn): AutocompleteStart {
return useMemo(
() => ({
getQuerySuggestions: () => undefined,
hasQuerySuggestions: (_) => false,
getValueSuggestions: fn,
}),
[fn]
);
}

View file

@ -7,6 +7,9 @@
import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants';
import type { HttpStart } from '@kbn/core/public';
import type { EndpointSuggestionsBody } from '../../../../../common/endpoint/schema/suggestions';
import { SUGGESTIONS_ROUTE } from '../../../../../common/endpoint/constants';
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client';
import { EVENT_FILTER_LIST_DEFINITION } from '../constants';
@ -23,4 +26,18 @@ export class EventFiltersApiClient extends ExceptionsListApiClient {
public static getInstance(http: HttpStart): ExceptionsListApiClient {
return super.getInstance(http, ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_DEFINITION);
}
/**
* Returns suggestions for given field
*/
async getSuggestions(body: EndpointSuggestionsBody): Promise<string[]> {
const result: string[] = await this.getHttp().post(
resolvePathVariables(SUGGESTIONS_ROUTE, { suggestion_type: 'eventFilters' }),
{
body: JSON.stringify(body),
}
);
return result;
}
}

View file

@ -27,6 +27,9 @@ import { OperatingSystem } from '@kbn/securitysolution-utils';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
import type { OnChangeProps } from '@kbn/lists-plugin/public';
import type { ValueSuggestionsGetFn } from '@kbn/unified-search-plugin/public/autocomplete/providers/value_suggestion_provider';
import { eventsIndexPattern } from '../../../../../../common/endpoint/constants';
import { useSuggestions } from '../../../../hooks/use_suggestions';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import type { PolicyData } from '../../../../../../common/endpoint/types';
import { useFetchIndex } from '../../../../../common/containers/source';
@ -57,6 +60,7 @@ import { EffectedPolicySelect } from '../../../../components/effected_policy_sel
import { isGlobalPolicyEffected } from '../../../../components/effected_policy_select/utils';
import { ExceptionItemComments } from '../../../../../detection_engine/rule_exceptions/components/item_comments';
import { filterIndexPatterns } from '../../../../../detection_engine/rule_exceptions/utils/helpers';
import { EventFiltersApiClient } from '../../service/api_client';
const OPERATING_SYSTEMS: readonly OperatingSystem[] = [
OperatingSystem.MAC,
@ -113,8 +117,17 @@ type EventFilterItemEntries = Array<{
export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSelectOs?: boolean }> =
memo(({ allowSelectOs = true, item: exception, policies, policiesIsLoading, onChange, mode }) => {
const getTestId = useTestIdGenerator('eventFilters-form');
const { http, unifiedSearch } = useKibana().services;
const { http } = useKibana().services;
const getSuggestionsFn = useCallback<ValueSuggestionsGetFn>(
({ field, query }) => {
const eventFiltersAPIClient = new EventFiltersApiClient(http);
return eventFiltersAPIClient.getSuggestions({ field: field.name, query });
},
[http]
);
const autocompleteSuggestions = useSuggestions(getSuggestionsFn);
const [hasFormChanged, setHasFormChanged] = useState(false);
const [hasNameError, toggleHasNameError] = useState<boolean>(!exception.name);
const [newComment, setNewComment] = useState('');
@ -129,8 +142,13 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
const [hasDuplicateFields, setHasDuplicateFields] = useState<boolean>(false);
// This value has to be memoized to avoid infinite useEffect loop on useFetchIndex
const indexNames = useMemo(() => ['logs-endpoint.events.*'], []);
const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames);
const indexNames = useMemo(() => [eventsIndexPattern], []);
const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(
indexNames,
undefined,
'eventFiltersFields'
);
const [areConditionsValid, setAreConditionsValid] = useState(
!!exception.entries.length || false
);
@ -416,7 +434,7 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
getExceptionBuilderComponentLazy({
allowLargeValueLists: false,
httpService: http,
autocompleteService: unifiedSearch.autocomplete,
autocompleteService: autocompleteSuggestions,
exceptionListItems: [eventFilterItem as ExceptionListItemSchema],
listType: EVENT_FILTER_LIST_TYPE,
listId: ENDPOINT_EVENT_FILTERS_LIST_ID,
@ -434,7 +452,14 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
operatorsList: EVENT_FILTERS_OPERATORS,
osTypes: exception.os_types,
}),
[unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception, eventFilterItem]
[
autocompleteSuggestions,
handleOnBuilderChange,
http,
indexPatterns,
exception,
eventFilterItem,
]
);
// conditions

View file

@ -94,6 +94,10 @@ export class ExceptionsListApiClient {
return this.http === coreHttp;
}
protected getHttp(): HttpStart {
return this.http;
}
/**
* Static method to get a fresh or existing instance.
* It will ensure we only check and create the list once.

View file

@ -42,6 +42,7 @@ import { createCasesClientMock } from '@kbn/cases-plugin/server/client/mocks';
import { createFleetAuthzMock } from '@kbn/fleet-plugin/common';
import type { RequestFixtureOptions } from '@kbn/core-http-router-server-mocks';
import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { getEndpointAuthzInitialStateMock } from '../../common/endpoint/service/authz/mocks';
import { xpackMocks } from '../fixtures';
import { createMockConfig, requestContextMock } from '../lib/detection_engine/routes/__mocks__';
import type {
@ -94,6 +95,7 @@ export const createMockEndpointAppContextService = (
getManifestManager: jest.fn().mockReturnValue(mockManifestManager ?? jest.fn()),
getEndpointMetadataService: jest.fn(() => mockEndpointMetadataContext.endpointMetadataService),
getInternalFleetServices: jest.fn(() => mockEndpointMetadataContext.fleetServices),
getEndpointAuthz: jest.fn(getEndpointAuthzInitialStateMock),
} as unknown as jest.Mocked<EndpointAppContextService>;
};

View file

@ -0,0 +1,201 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import type { ScopedClusterClientMock } from '@kbn/core/server/mocks';
import {
loggingSystemMock,
elasticsearchServiceMock,
savedObjectsClientMock,
httpServerMock,
httpServiceMock,
} from '@kbn/core/server/mocks';
import type { KibanaResponseFactory, SavedObjectsClientContract } from '@kbn/core/server';
import type { ConfigSchema } from '@kbn/unified-search-plugin/config';
import type { Observable } from 'rxjs';
import { dataPluginMock } from '@kbn/unified-search-plugin/server/mocks';
import { termsEnumSuggestions } from '@kbn/unified-search-plugin/server/autocomplete/terms_enum';
import {
createMockEndpointAppContext,
createMockEndpointAppContextServiceStartContract,
createRouteHandlerContext,
} from '../../mocks';
import type { EndpointAuthz } from '../../../../common/endpoint/types/authz';
import { applyActionsEsSearchMock } from '../../services/actions/mocks';
import {
createMockConfig,
requestContextMock,
} from '../../../lib/detection_engine/routes/__mocks__';
import type { EndpointSuggestionsSchema } from '../../../../common/endpoint/schema/suggestions';
import {
getEndpointSuggestionsRequestHandler,
registerEndpointSuggestionsRoutes,
getLogger,
} from '.';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
import { eventsIndexPattern, SUGGESTIONS_ROUTE } from '../../../../common/endpoint/constants';
import { EndpointAppContextService } from '../../endpoint_app_context_services';
import { parseExperimentalConfigValue } from '../../../../common/experimental_features';
jest.mock('@kbn/unified-search-plugin/server/autocomplete/terms_enum', () => {
return {
termsEnumSuggestions: jest.fn(),
};
});
const termsEnumSuggestionsMock = termsEnumSuggestions as jest.Mock;
interface CallRouteInterface {
params: TypeOf<typeof EndpointSuggestionsSchema.params>;
authz?: Partial<EndpointAuthz>;
}
describe('when calling the Suggestions route handler', () => {
let mockScopedEsClient: ScopedClusterClientMock;
let mockSavedObjectClient: jest.Mocked<SavedObjectsClientContract>;
let mockResponse: jest.Mocked<KibanaResponseFactory>;
let suggestionsRouteHandler: ReturnType<typeof getEndpointSuggestionsRequestHandler>;
let callRoute: (
routePrefix: string,
opts: CallRouteInterface,
indexExists?: { endpointDsExists: boolean }
) => Promise<void>;
let config$: Observable<ConfigSchema>;
beforeEach(() => {
const mockContext = createMockEndpointAppContext();
(mockContext.service.getEndpointMetadataService as jest.Mock) = jest.fn().mockReturnValue({
findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]),
});
mockScopedEsClient = elasticsearchServiceMock.createScopedClusterClient();
mockSavedObjectClient = savedObjectsClientMock.create();
mockResponse = httpServerMock.createResponseFactory();
config$ = dataPluginMock
.createSetupContract()
.autocomplete.getInitializerContextConfig()
.create();
suggestionsRouteHandler = getEndpointSuggestionsRequestHandler(config$, getLogger(mockContext));
});
describe('having right privileges', () => {
it('should call service using event filters type from request', async () => {
applyActionsEsSearchMock(mockScopedEsClient.asInternalUser);
const mockContext = requestContextMock.convertContext(
createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient)
);
const mockRequest = httpServerMock.createKibanaRequest<
TypeOf<typeof EndpointSuggestionsSchema.params>,
never,
never
>({
params: { suggestion_type: 'eventFilters' },
body: {
field: 'test-field',
query: 'test-query',
filters: 'test-filters',
fieldMeta: 'test-field-meta',
},
});
await suggestionsRouteHandler(mockContext, mockRequest, mockResponse);
expect(termsEnumSuggestionsMock).toHaveBeenNthCalledWith(
1,
expect.any(Object),
expect.any(Object),
expect.any(Object),
eventsIndexPattern,
'test-field',
'test-query',
'test-filters',
'test-field-meta',
expect.any(Object)
);
expect(mockResponse.ok).toHaveBeenCalled();
});
it('should respond with bad request if wrong suggestion type', async () => {
applyActionsEsSearchMock(
mockScopedEsClient.asInternalUser,
new EndpointActionGenerator().toEsSearchResponse([])
);
const mockContext = requestContextMock.convertContext(
createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient)
);
const mockRequest = httpServerMock.createKibanaRequest<
TypeOf<typeof EndpointSuggestionsSchema.params>,
never,
never
>({
params: { suggestion_type: 'any' },
});
await suggestionsRouteHandler(mockContext, mockRequest, mockResponse);
expect(mockResponse.badRequest).toHaveBeenCalledWith({
body: 'Invalid suggestion_type: any',
});
});
});
describe('without having right privileges', () => {
beforeEach(() => {
const startContract = createMockEndpointAppContextServiceStartContract();
const routerMock = httpServiceMock.createRouter();
const endpointAppContextService = new EndpointAppContextService();
// add the suggestions route handlers to routerMock
registerEndpointSuggestionsRoutes(routerMock, config$, {
logFactory: loggingSystemMock.create(),
service: endpointAppContextService,
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
});
// define a convenience function to execute an API call for a given route
callRoute = async (
routePrefix: string,
{ params, authz = {} }: CallRouteInterface
): Promise<void> => {
const superUser = {
username: 'superuser',
roles: ['superuser'],
};
(startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce(
() => superUser
);
const ctx = createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient);
ctx.securitySolution.getEndpointAuthz.mockResolvedValue(
getEndpointAuthzInitialStateMock(authz)
);
const mockRequest = httpServerMock.createKibanaRequest({ params });
const [, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) =>
path.startsWith(routePrefix)
)!;
await routeHandler(ctx, mockRequest, mockResponse);
};
});
it('should respond with forbidden', async () => {
await callRoute(SUGGESTIONS_ROUTE, {
params: { suggestion_type: 'eventFilters' },
authz: { canReadEventFilters: true, canWriteEventFilters: false },
});
expect(mockResponse.forbidden).toBeCalled();
});
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 { Observable } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import type { RequestHandler, Logger } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { getRequestAbortedSignal } from '@kbn/data-plugin/server';
import type { ConfigSchema } from '@kbn/unified-search-plugin/config';
import { termsEnumSuggestions } from '@kbn/unified-search-plugin/server/autocomplete/terms_enum';
import {
type EndpointSuggestionsBody,
EndpointSuggestionsSchema,
} from '../../../../common/endpoint/schema/suggestions';
import type {
SecuritySolutionPluginRouter,
SecuritySolutionRequestHandlerContext,
} from '../../../types';
import type { EndpointAppContext } from '../../types';
import { eventsIndexPattern, SUGGESTIONS_ROUTE } from '../../../../common/endpoint/constants';
import { withEndpointAuthz } from '../with_endpoint_authz';
import { errorHandler } from '../error_handler';
export const getLogger = (endpointAppContext: EndpointAppContext): Logger => {
return endpointAppContext.logFactory.get('suggestions');
};
export function registerEndpointSuggestionsRoutes(
router: SecuritySolutionPluginRouter,
config$: Observable<ConfigSchema>,
endpointContext: EndpointAppContext
) {
router.post(
{
path: SUGGESTIONS_ROUTE,
validate: EndpointSuggestionsSchema,
},
withEndpointAuthz(
{ any: ['canWriteEventFilters'] },
endpointContext.logFactory.get('endpointSuggestions'),
getEndpointSuggestionsRequestHandler(config$, getLogger(endpointContext))
)
);
}
export const getEndpointSuggestionsRequestHandler = (
config$: Observable<ConfigSchema>,
logger: Logger
): RequestHandler<
TypeOf<typeof EndpointSuggestionsSchema.params>,
never,
EndpointSuggestionsBody,
SecuritySolutionRequestHandlerContext
> => {
return async (context, request, response) => {
const config = await firstValueFrom(config$);
const { field: fieldName, query, filters, fieldMeta } = request.body;
let index = '';
if (request.params.suggestion_type === 'eventFilters') {
index = eventsIndexPattern;
} else {
return response.badRequest({
body: `Invalid suggestion_type: ${request.params.suggestion_type}`,
});
}
const abortSignal = getRequestAbortedSignal(request.events.aborted$);
const { savedObjects, elasticsearch } = await context.core;
try {
const body = await termsEnumSuggestions(
config,
savedObjects.client,
elasticsearch.client.asInternalUser,
index,
fieldName,
query,
filters,
fieldMeta,
abortSignal
);
return response.ok({ body });
} catch (error) {
return errorHandler(logger, response, error);
}
};
};

View file

@ -56,6 +56,7 @@ import {
import { registerEndpointRoutes } from './endpoint/routes/metadata';
import { registerPolicyRoutes } from './endpoint/routes/policy';
import { registerActionRoutes } from './endpoint/routes/actions';
import { registerEndpointSuggestionsRoutes } from './endpoint/routes/suggestions';
import { EndpointArtifactClient, ManifestManager } from './endpoint/services';
import { EndpointAppContextService } from './endpoint/endpoint_app_context_services';
import type { EndpointAppContext } from './endpoint/types';
@ -102,6 +103,7 @@ import { EndpointFleetServicesFactory } from './endpoint/services/fleet';
import { featureUsageService } from './endpoint/services/feature_usage';
import { setIsElasticCloudDeployment } from './lib/telemetry/helpers';
import { artifactService } from './lib/telemetry/artifact';
import { eventFiltersFieldsProvider } from './search_strategy/event_filters_fields';
export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract';
@ -301,7 +303,13 @@ export class Plugin implements ISecuritySolutionPlugin {
previewRuleDataClient,
this.telemetryReceiver
);
registerEndpointRoutes(router, endpointContext);
registerEndpointSuggestionsRoutes(
router,
plugins.unifiedSearch.autocomplete.getInitializerContextConfig().create(),
endpointContext
);
registerLimitedConcurrencyRoutes(core);
registerPolicyRoutes(router, endpointContext);
registerActionRoutes(router, endpointContext);
@ -349,6 +357,12 @@ export class Plugin implements ISecuritySolutionPlugin {
config,
});
const eventFiltersFieldsStrategy = eventFiltersFieldsProvider(
this.endpointAppContextService,
depsStart.data.indexPatterns
);
plugins.data.search.registerSearchStrategy('eventFiltersFields', eventFiltersFieldsStrategy);
const securitySolutionSearchStrategy = securitySolutionSearchStrategyProvider(
depsStart.data,
endpointContext,

View file

@ -37,6 +37,7 @@ import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-
import type { OsqueryPluginSetup } from '@kbn/osquery-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server';
export interface SecuritySolutionPluginSetupDependencies {
alerting: AlertingPluginSetup;
@ -55,6 +56,7 @@ export interface SecuritySolutionPluginSetupDependencies {
usageCollection?: UsageCollectionPluginSetup;
licensing: LicensingPluginSetup;
osquery: OsqueryPluginSetup;
unifiedSearch: UnifiedSearchServerPluginSetup;
}
export interface SecuritySolutionPluginStartDependencies {

View file

@ -0,0 +1,187 @@
/*
* 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 {
SearchStrategyDependencies,
DataViewsServerPluginStart,
} from '@kbn/data-plugin/server';
import { fieldsBeat as beatFields } from '@kbn/timelines-plugin/server/utils/beat_schema/fields';
import { IndexPatternsFetcher } from '@kbn/data-plugin/server';
import { requestEventFiltersFieldsSearch } from '.';
import { createMockEndpointAppContextService } from '../../endpoint/mocks';
import { getEndpointAuthzInitialStateMock } from '../../../common/endpoint/service/authz/mocks';
import { eventsIndexPattern } from '../../../common/endpoint/constants';
import { EndpointAuthorizationError } from '../../endpoint/errors';
describe('Event filters fields', () => {
const getFieldsForWildcardMock = jest.fn();
const esClientSearchMock = jest.fn();
const esClientFieldCapsMock = jest.fn();
const endpointAppContextService = createMockEndpointAppContextService();
let IndexPatterns: DataViewsServerPluginStart;
const deps = {
esClient: {
asInternalUser: { search: esClientSearchMock, fieldCaps: esClientFieldCapsMock },
},
} as unknown as SearchStrategyDependencies;
const mockPattern = {
title: 'test',
fields: {
toSpec: () => ({
coolio: {
name: 'name_test',
type: 'type_test',
searchable: true,
aggregatable: true,
},
}),
},
toSpec: () => ({
runtimeFieldMap: { runtimeField: { type: 'keyword' } },
}),
};
const getStartServices = jest.fn().mockReturnValue([
null,
{
data: {
indexPatterns: {
dataViewsServiceFactory: () => ({
get: jest.fn().mockReturnValue(mockPattern),
}),
},
},
},
]);
beforeAll(() => {
getFieldsForWildcardMock.mockResolvedValue([]);
esClientSearchMock.mockResolvedValue({ hits: { total: { value: 123 } } });
esClientFieldCapsMock.mockResolvedValue({ indices: ['value'] });
IndexPatternsFetcher.prototype.getFieldsForWildcard = getFieldsForWildcardMock;
});
beforeEach(async () => {
const [
,
{
data: { indexPatterns },
},
] = await getStartServices();
IndexPatterns = indexPatterns;
getFieldsForWildcardMock.mockClear();
esClientSearchMock.mockClear();
esClientFieldCapsMock.mockClear();
});
afterAll(() => {
getFieldsForWildcardMock.mockRestore();
});
describe('with right privileges', () => {
it('should check index exists', async () => {
const indices = [eventsIndexPattern];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
const response = await requestEventFiltersFieldsSearch(
endpointAppContextService,
request,
deps,
beatFields,
IndexPatterns
);
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should search index fields', async () => {
const indices = [eventsIndexPattern];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
const response = await requestEventFiltersFieldsSearch(
endpointAppContextService,
request,
deps,
beatFields,
IndexPatterns
);
expect(getFieldsForWildcardMock).toHaveBeenCalledWith({ pattern: indices[0] });
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should throw when invalid index', async () => {
const indices = ['invalid'];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
await expect(async () => {
await requestEventFiltersFieldsSearch(
endpointAppContextService,
request,
deps,
beatFields,
IndexPatterns
);
}).rejects.toThrowError('Invalid indices request invalid');
});
it('should throw when more than one index', async () => {
const indices = ['invalid', 'invalid2'];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
await expect(async () => {
await requestEventFiltersFieldsSearch(
endpointAppContextService,
request,
deps,
beatFields,
IndexPatterns
);
}).rejects.toThrowError('Invalid indices request invalid, invalid2');
});
});
describe('without right privileges', () => {
beforeEach(() => {
(endpointAppContextService.getEndpointAuthz as jest.Mock).mockResolvedValue(
getEndpointAuthzInitialStateMock({ canReadEventFilters: true, canWriteEventFilters: false })
);
});
it('should throw because not enough privileges', async () => {
const indices = [eventsIndexPattern];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
await expect(async () => {
await requestEventFiltersFieldsSearch(
endpointAppContextService,
request,
deps,
beatFields,
IndexPatterns
);
}).rejects.toThrowError(new EndpointAuthorizationError());
});
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 { from } from 'rxjs';
import type {
DataViewsServerPluginStart,
ISearchStrategy,
SearchStrategyDependencies,
} from '@kbn/data-plugin/server';
import { requestIndexFieldSearch } from '@kbn/timelines-plugin/server/search_strategy/index_fields';
import { eventsIndexPattern } from '../../../common/endpoint/constants';
import type {
BeatFields,
IndexFieldsStrategyRequest,
IndexFieldsStrategyResponse,
} from '../../../common/search_strategy';
import type { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services';
import { EndpointAuthorizationError } from '../../endpoint/errors';
/**
* EventFiltersFieldProvider mimics indexField provider from timeline plugin: x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts
* but it uses ES internalUser instead to avoid adding extra index privileges for users with event filters permissions.
* It is used to retrieve index patterns for event filters form.
*/
export const eventFiltersFieldsProvider = (
context: EndpointAppContextService,
indexPatterns: DataViewsServerPluginStart
): ISearchStrategy<IndexFieldsStrategyRequest<'indices'>, IndexFieldsStrategyResponse> => {
// require the fields once we actually need them, rather than ahead of time, and pass
// them to createFieldItem to reduce the amount of work done as much as possible
const beatFields: BeatFields =
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('@kbn/timelines-plugin/server/utils/beat_schema/fields').fieldsBeat;
return {
search: (request, _, deps) =>
from(requestEventFiltersFieldsSearch(context, request, deps, beatFields, indexPatterns)),
};
};
export const requestEventFiltersFieldsSearch = async (
context: EndpointAppContextService,
request: IndexFieldsStrategyRequest<'indices'>,
deps: SearchStrategyDependencies,
beatFields: BeatFields,
indexPatterns: DataViewsServerPluginStart
): Promise<IndexFieldsStrategyResponse> => {
const { canWriteEventFilters } = await context.getEndpointAuthz(deps.request);
if (!canWriteEventFilters) {
throw new EndpointAuthorizationError();
}
if (request.indices.length > 1 || request.indices[0] !== eventsIndexPattern) {
throw new Error(`Invalid indices request ${request.indices.join(', ')}`);
}
return requestIndexFieldSearch(request, deps, beatFields, indexPatterns, true);
};

View file

@ -7,7 +7,7 @@
import { sortBy } from 'lodash/fp';
import { formatIndexFields, createFieldItem, requestIndexFieldSearch } from '.';
import { formatIndexFields, createFieldItem, requestIndexFieldSearchHandler } from '.';
import { mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField } from './mock';
import { fieldsBeat as beatFields } from '../../utils/beat_schema/fields';
import { IndexPatternsFetcher, SearchStrategyDependencies } from '@kbn/data-plugin/server';
@ -231,146 +231,199 @@ describe('Fields Provider', () => {
},
]);
const deps = {
const depsCurrentESUser = {
esClient: { asCurrentUser: { search: esClientSearchMock, fieldCaps: esClientFieldCapsMock } },
} as unknown as SearchStrategyDependencies;
beforeAll(() => {
getFieldsForWildcardMock.mockResolvedValue([]);
const depsInternalESUser = {
esClient: {
asInternalUser: { search: esClientSearchMock, fieldCaps: esClientFieldCapsMock },
},
} as unknown as SearchStrategyDependencies;
esClientSearchMock.mockResolvedValue({ hits: { total: { value: 123 } } });
esClientFieldCapsMock.mockResolvedValue({ indices: ['value'] });
IndexPatternsFetcher.prototype.getFieldsForWildcard = getFieldsForWildcardMock;
});
describe.each([
['currentESUser', depsCurrentESUser, false],
['internalESUser', depsInternalESUser, true],
])(`Using %s`, (_, deps, useInternalUser) => {
beforeAll(() => {
getFieldsForWildcardMock.mockResolvedValue([]);
beforeEach(() => {
getFieldsForWildcardMock.mockClear();
esClientSearchMock.mockClear();
esClientFieldCapsMock.mockClear();
});
afterAll(() => {
getFieldsForWildcardMock.mockRestore();
});
it('should check index exists', async () => {
const indices = ['some-index-pattern-*'];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should search index fields', async () => {
const indices = ['some-index-pattern-*'];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(getFieldsForWildcardMock).toHaveBeenCalledWith({ pattern: indices[0] });
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should search index fields by data view id', async () => {
const dataViewId = 'id';
const request = {
dataViewId,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(getFieldsForWildcardMock).not.toHaveBeenCalled();
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(['coolbro']);
});
it('onlyCheckIfIndicesExist by data view id', async () => {
const dataViewId = 'id';
const request = {
dataViewId,
onlyCheckIfIndicesExist: true,
};
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(['coolbro']);
});
it('should search apm index fields', async () => {
const indices = ['apm-*-transaction*', 'traces-apm*'];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(getFieldsForWildcardMock).toHaveBeenCalledWith({ pattern: indices[0] });
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should check apm index exists with data', async () => {
const indices = ['apm-*-transaction*', 'traces-apm*'];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
esClientSearchMock.mockResolvedValue({ hits: { total: { value: 1 } } });
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[0],
body: { query: { match_all: {} }, size: 0 },
});
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[1],
body: { query: { match_all: {} }, size: 0 },
});
expect(getFieldsForWildcardMock).not.toHaveBeenCalled();
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should check apm index exists with no data', async () => {
const indices = ['apm-*-transaction*', 'traces-apm*'];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
esClientSearchMock.mockResolvedValue({
body: { hits: { total: { value: 0 } } },
esClientSearchMock.mockResolvedValue({ hits: { total: { value: 123 } } });
esClientFieldCapsMock.mockResolvedValue({ indices: ['value'] });
IndexPatternsFetcher.prototype.getFieldsForWildcard = getFieldsForWildcardMock;
});
const response = await requestIndexFieldSearch(request, deps, beatFields, getStartServices);
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[0],
body: { query: { match_all: {} }, size: 0 },
beforeEach(() => {
getFieldsForWildcardMock.mockClear();
esClientSearchMock.mockClear();
esClientFieldCapsMock.mockClear();
});
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[1],
body: { query: { match_all: {} }, size: 0 },
});
expect(getFieldsForWildcardMock).not.toHaveBeenCalled();
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual([]);
afterAll(() => {
getFieldsForWildcardMock.mockRestore();
});
it('should check index exists', async () => {
const indices = ['some-index-pattern-*'];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should search index fields', async () => {
const indices = ['some-index-pattern-*'];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(getFieldsForWildcardMock).toHaveBeenCalledWith({ pattern: indices[0] });
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should search index fields by data view id', async () => {
const dataViewId = 'id';
const request = {
dataViewId,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(getFieldsForWildcardMock).not.toHaveBeenCalled();
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(['coolbro']);
});
it('onlyCheckIfIndicesExist by data view id', async () => {
const dataViewId = 'id';
const request = {
dataViewId,
onlyCheckIfIndicesExist: true,
};
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(['coolbro']);
});
it('should search apm index fields', async () => {
const indices = ['apm-*-transaction*', 'traces-apm*'];
const request = {
indices,
onlyCheckIfIndicesExist: false,
};
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(getFieldsForWildcardMock).toHaveBeenCalledWith({ pattern: indices[0] });
expect(response.indexFields).not.toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should check apm index exists with data', async () => {
const indices = ['apm-*-transaction*', 'traces-apm*'];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
esClientSearchMock.mockResolvedValue({ hits: { total: { value: 1 } } });
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[0],
body: { query: { match_all: {} }, size: 0 },
});
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[1],
body: { query: { match_all: {} }, size: 0 },
});
expect(getFieldsForWildcardMock).not.toHaveBeenCalled();
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual(indices);
});
it('should check apm index exists with no data', async () => {
const indices = ['apm-*-transaction*', 'traces-apm*'];
const request = {
indices,
onlyCheckIfIndicesExist: true,
};
esClientSearchMock.mockResolvedValue({
body: { hits: { total: { value: 0 } } },
});
const response = await requestIndexFieldSearchHandler(
request,
deps,
beatFields,
getStartServices,
useInternalUser
);
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[0],
body: { query: { match_all: {} }, size: 0 },
});
expect(esClientSearchMock).toHaveBeenCalledWith({
index: indices[1],
body: { query: { match_all: {} }, size: 0 },
});
expect(getFieldsForWildcardMock).not.toHaveBeenCalled();
expect(response.indexFields).toHaveLength(0);
expect(response.indicesExist).toEqual([]);
});
});
});
});

View file

@ -10,6 +10,7 @@ import isEmpty from 'lodash/isEmpty';
import get from 'lodash/get';
import { ElasticsearchClient, StartServicesAccessor } from '@kbn/core/server';
import {
DataViewsServerPluginStart,
IndexPatternsFetcher,
ISearchStrategy,
SearchStrategyDependencies,
@ -41,7 +42,7 @@ export const indexFieldsProvider = (
return {
search: (request, options, deps) =>
from(requestIndexFieldSearch(request, deps, beatFields, getStartServices)),
from(requestIndexFieldSearchHandler(request, deps, beatFields, getStartServices)),
};
};
@ -70,27 +71,40 @@ export const findExistingIndices = async (
.map((p) => p.catch((e) => false))
);
export const requestIndexFieldSearch = async (
export const requestIndexFieldSearchHandler = async (
request: IndexFieldsStrategyRequest<'indices' | 'dataView'>,
{ savedObjectsClient, esClient, request: kRequest }: SearchStrategyDependencies,
deps: SearchStrategyDependencies,
beatFields: BeatFields,
getStartServices: StartServicesAccessor<StartPlugins>
getStartServices: StartServicesAccessor<StartPlugins>,
useInternalUser?: boolean
): Promise<IndexFieldsStrategyResponse> => {
const indexPatternsFetcherAsCurrentUser = new IndexPatternsFetcher(esClient.asCurrentUser);
const indexPatternsFetcherAsInternalUser = new IndexPatternsFetcher(esClient.asInternalUser);
if ('dataViewId' in request && 'indices' in request) {
throw new Error('Provide index field search with either `dataViewId` or `indices`, not both');
}
const [
,
{
data: { indexPatterns },
},
] = await getStartServices();
return requestIndexFieldSearch(request, deps, beatFields, indexPatterns, useInternalUser);
};
export const requestIndexFieldSearch = async (
request: IndexFieldsStrategyRequest<'indices' | 'dataView'>,
{ savedObjectsClient, esClient, request: kRequest }: SearchStrategyDependencies,
beatFields: BeatFields,
indexPatterns: DataViewsServerPluginStart,
useInternalUser?: boolean
): Promise<IndexFieldsStrategyResponse> => {
const indexPatternsFetcherAsCurrentUser = new IndexPatternsFetcher(esClient.asCurrentUser);
const indexPatternsFetcherAsInternalUser = new IndexPatternsFetcher(esClient.asInternalUser);
if ('dataViewId' in request && 'indices' in request) {
throw new Error('Provide index field search with either `dataViewId` or `indices`, not both');
}
const esUser = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
const dataViewService = await indexPatterns.dataViewsServiceFactory(
savedObjectsClient,
esClient.asCurrentUser,
esUser,
kRequest,
true
);
@ -118,7 +132,7 @@ export const requestIndexFieldSearch = async (
}
const patternList = dataView.title.split(',');
indicesExist = (await findExistingIndices(patternList, esClient.asCurrentUser)).reduce(
indicesExist = (await findExistingIndices(patternList, esUser)).reduce(
(acc: string[], doesIndexExist, i) => (doesIndexExist ? [...acc, patternList[i]] : acc),
[]
);
@ -131,7 +145,7 @@ export const requestIndexFieldSearch = async (
}
} else if ('indices' in request) {
const patternList = dedupeIndexName(request.indices);
indicesExist = (await findExistingIndices(patternList, esClient.asCurrentUser)).reduce(
indicesExist = (await findExistingIndices(patternList, esUser)).reduce(
(acc: string[], doesIndexExist, i) => (doesIndexExist ? [...acc, patternList[i]] : acc),
[]
);
@ -139,7 +153,7 @@ export const requestIndexFieldSearch = async (
const fieldDescriptor = (
await Promise.all(
indicesExist.map(async (index, n) => {
if (index.startsWith('.alerts-observability')) {
if (index.startsWith('.alerts-observability') || useInternalUser) {
return indexPatternsFetcherAsInternalUser.getFieldsForWildcard({
pattern: index,
});