[SECURITY SOLUTION] Allow the application to create its own data view without using user privilege (#121109) (#121816)

* backend update to use unsecure so

* wip on UI

* fix UI to work with one dataview at the time

* by pass capabilities in data view factory

* fix sourcerer in timeline

* fix types

* fix unit test

* fix index field to work with security data view

* cypress + detection roles tests

* add unit test

* review I

* review II

* review III

* clean up after talking to Larry

* fix latets code

* working to be green

* by pass capabilities from data view API only use saved object kibana privilege

* fix lint

* add commnet per review

Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
This commit is contained in:
Kibana Machine 2021-12-21 15:01:18 -05:00 committed by GitHub
parent a0aea6c112
commit f5add805e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 466 additions and 230 deletions

View file

@ -20,23 +20,23 @@ import { UiSettingsServerToCommon } from './ui_settings_wrapper';
import { IndexPatternsApiServer } from './index_patterns_api_client';
import { SavedObjectsClientServerToCommon } from './saved_objects_client_wrapper';
export const dataViewsServiceFactory =
({
logger,
uiSettings,
fieldFormats,
capabilities,
}: {
logger: Logger;
uiSettings: UiSettingsServiceStart;
fieldFormats: FieldFormatsStart;
capabilities: CoreStart['capabilities'];
}) =>
async (
export const dataViewsServiceFactory = ({
logger,
uiSettings,
fieldFormats,
capabilities,
}: {
logger: Logger;
uiSettings: UiSettingsServiceStart;
fieldFormats: FieldFormatsStart;
capabilities: CoreStart['capabilities'];
}) =>
async function (
savedObjectsClient: SavedObjectsClientContract,
elasticsearchClient: ElasticsearchClient,
request?: KibanaRequest
) => {
request?: KibanaRequest,
byPassCapabilities?: boolean
) {
const uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient);
const formats = await fieldFormats.fieldFormatServiceFactory(uiSettingsClient);
@ -52,7 +52,9 @@ export const dataViewsServiceFactory =
logger.warn(`${title}${text ? ` : ${text}` : ''}`);
},
getCanSave: async () =>
request
byPassCapabilities
? true
: request
? (await capabilities.resolveCapabilities(request)).indexPatterns.save === true
: false,
});

View file

@ -20,7 +20,8 @@ import { FieldFormatsSetup, FieldFormatsStart } from '../../field_formats/server
type ServiceFactory = (
savedObjectsClient: SavedObjectsClientContract,
elasticsearchClient: ElasticsearchClient,
request?: KibanaRequest
request?: KibanaRequest,
byPassCapabilities?: boolean
) => Promise<DataViewsService>;
export interface DataViewsServerPluginStart {
dataViewsServiceFactory: ServiceFactory;

View file

@ -271,7 +271,7 @@ export const TIMELINE_PREPACKAGED_URL = `${TIMELINE_URL}/_prepackaged` as const;
export const NOTE_URL = '/api/note' as const;
export const PINNED_EVENT_URL = '/api/pinned_event' as const;
export const SOURCERER_API_URL = '/api/sourcerer' as const;
export const SOURCERER_API_URL = '/internal/security_solution/sourcerer' as const;
/**
* Default signals index key for kibana.dev.yml

View file

@ -66,10 +66,6 @@ export const secAll: Role = {
securitySolutionCases: ['all'],
actions: ['all'],
actionsSimulators: ['all'],
// TODO: Steph/sourcerer remove once we have our internal saved object client
// https://github.com/elastic/security-team/issues/1978
indexPatterns: ['read'],
savedObjectsManagement: ['read'],
},
spaces: ['*'],
},
@ -101,10 +97,6 @@ export const secReadCasesAll: Role = {
securitySolutionCases: ['all'],
actions: ['all'],
actionsSimulators: ['all'],
// TODO: Steph/sourcerer remove once we have our internal saved object client
// https://github.com/elastic/security-team/issues/1978
indexPatterns: ['read'],
savedObjectsManagement: ['read'],
},
spaces: ['*'],
},

View file

@ -86,9 +86,10 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
const {
allOptions,
dataViewSelectOptions,
loadingIndexPatterns,
isModified,
handleOutsideClick,
onChangeCombo,
onChangeCombo: onChangeIndexPatterns,
renderOption,
selectedOptions,
setIndexPatternsByDataView,
@ -120,7 +121,7 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
setPopoverIsOpen((prevState) => !prevState);
setExpandAdvancedOptions(false); // we always want setExpandAdvancedOptions collapsed by default when popover opened
}, []);
const onChangeDataView = useCallback(
const dispatchChangeDataView = useCallback(
(
newSelectedDataView: string,
newSelectedPatterns: string[],
@ -138,7 +139,7 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
[dispatch, scopeId]
);
const onChangeSuper = useCallback(
const onChangeDataView = useCallback(
(newSelectedOption) => {
setDataViewId(newSelectedOption);
setIndexPatternsByDataView(newSelectedOption);
@ -156,10 +157,10 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
const handleSaveIndices = useCallback(() => {
const patterns = selectedOptions.map((so) => so.label);
if (dataViewId != null) {
onChangeDataView(dataViewId, patterns);
dispatchChangeDataView(dataViewId, patterns);
}
setPopoverIsOpen(false);
}, [onChangeDataView, dataViewId, selectedOptions]);
}, [dispatchChangeDataView, dataViewId, selectedOptions]);
const handleClosePopOver = useCallback(() => {
setPopoverIsOpen(false);
@ -172,9 +173,9 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
const patterns = selectedPatterns.filter((pattern) =>
defaultDataView.patternList.includes(pattern)
);
onChangeDataView(defaultDataView.id, patterns);
dispatchChangeDataView(defaultDataView.id, patterns);
setPopoverIsOpen(false);
}, [defaultDataView.id, defaultDataView.patternList, onChangeDataView, selectedPatterns]);
}, [defaultDataView.id, defaultDataView.patternList, dispatchChangeDataView, selectedPatterns]);
const onUpdateDeprecated = useCallback(() => {
// are all the patterns in the default?
@ -200,7 +201,7 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
setPopoverIsOpen(false);
if (isUiSettingsSuccess) {
onChangeDataView(
dispatchChangeDataView(
defaultDataView.id,
// to be at this stage, activePatterns is defined, the ?? selectedPatterns is to make TS happy
activePatterns ?? selectedPatterns,
@ -212,7 +213,7 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
activePatterns,
defaultDataView.id,
missingPatterns,
onChangeDataView,
dispatchChangeDataView,
selectedPatterns,
updateDataView,
]);
@ -300,9 +301,10 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
<StyledFormRow label={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}>
<EuiSuperSelect
data-test-subj="sourcerer-select"
isLoading={loadingIndexPatterns}
disabled={isOnlyDetectionAlerts}
fullWidth
onChange={onChangeSuper}
onChange={onChangeDataView}
options={dataViewSelectOptions}
placeholder={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}
valueOfSelected={dataViewId}
@ -321,6 +323,7 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
</StyledButton>
{expandAdvancedOptions && <EuiSpacer size="m" />}
<FormRow
isDisabled={loadingIndexPatterns}
$expandAdvancedOptions={expandAdvancedOptions}
helpText={isOnlyDetectionAlerts ? undefined : i18n.INDEX_PATTERNS_DESCRIPTIONS}
label={i18n.INDEX_PATTERNS_LABEL}
@ -328,8 +331,8 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
<EuiComboBox
data-test-subj="sourcerer-combo-box"
fullWidth
isDisabled={isOnlyDetectionAlerts}
onChange={onChangeCombo}
isDisabled={isOnlyDetectionAlerts || loadingIndexPatterns}
onChange={onChangeIndexPatterns}
options={allOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}

View file

@ -5,10 +5,13 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { EuiComboBoxOptionOption, EuiSuperSelectOption } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { getSourcererDataview } from '../../containers/sourcerer/api';
import { getScopePatternListSelection } from '../../store/sourcerer/helpers';
import { sourcererModel } from '../../store/sourcerer';
import { sourcererActions, sourcererModel } from '../../store/sourcerer';
import { getDataViewSelectOptions, getPatternListWithoutSignals } from './helpers';
import { SourcererScopeName } from '../../store/sourcerer/model';
@ -29,6 +32,7 @@ export type ModifiedTypes = 'modified' | 'alerts' | 'deprecated' | 'missingPatte
interface UsePickIndexPatterns {
allOptions: Array<EuiComboBoxOptionOption<string>>;
dataViewSelectOptions: Array<EuiSuperSelectOption<string>>;
loadingIndexPatterns: boolean;
handleOutsideClick: () => void;
isModified: ModifiedTypes;
onChangeCombo: (newSelectedDataViewId: Array<EuiComboBoxOptionOption<string>>) => void;
@ -55,10 +59,19 @@ export const usePickIndexPatterns = ({
selectedPatterns,
signalIndexName,
}: UsePickIndexPatternsProps): UsePickIndexPatterns => {
const dispatch = useDispatch();
const isHookAlive = useRef(true);
const [loadingIndexPatterns, setLoadingIndexPatterns] = useState(false);
const alertsOptions = useMemo(
() => (signalIndexName ? patternListToOptions([signalIndexName]) : []),
[signalIndexName]
);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>(
isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(selectedPatterns)
);
const [isModified, setIsModified] = useState<ModifiedTypes>(
dataViewId == null ? 'deprecated' : missingPatterns.length > 0 ? 'missingPatterns' : ''
);
const { allPatterns, selectablePatterns } = useMemo<{
allPatterns: string[];
@ -99,9 +112,6 @@ export const usePickIndexPatterns = ({
() => patternListToOptions(allPatterns, selectablePatterns),
[allPatterns, selectablePatterns]
);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>(
isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(selectedPatterns)
);
const getDefaultSelectedOptionsByDataView = useCallback(
(id: string, isAlerts: boolean = false): Array<EuiComboBoxOptionOption<string>> =>
@ -123,9 +133,6 @@ export const usePickIndexPatterns = ({
[dataViewId, getDefaultSelectedOptionsByDataView]
);
const [isModified, setIsModified] = useState<ModifiedTypes>(
dataViewId == null ? 'deprecated' : missingPatterns.length > 0 ? 'missingPatterns' : ''
);
const onSetIsModified = useCallback(
(patterns: string[], id: string | null) => {
if (id == null) {
@ -173,9 +180,47 @@ export const usePickIndexPatterns = ({
[]
);
const setIndexPatternsByDataView = (newSelectedDataViewId: string, isAlerts?: boolean) => {
setSelectedOptions(getDefaultSelectedOptionsByDataView(newSelectedDataViewId, isAlerts));
};
const setIndexPatternsByDataView = useCallback(
async (newSelectedDataViewId: string, isAlerts?: boolean) => {
if (
kibanaDataViews.some(
(kdv) => kdv.id === newSelectedDataViewId && kdv.indexFields.length === 0
)
) {
try {
setLoadingIndexPatterns(true);
setSelectedOptions([]);
// TODO We will need to figure out how to pass an abortController, but as right now this hook is
// constantly getting destroy and re-init
const pickedDataViewData = await getSourcererDataview(newSelectedDataViewId);
if (isHookAlive.current) {
dispatch(
sourcererActions.updateSourcererDataViews({
dataView: pickedDataViewData,
})
);
setSelectedOptions(
isOnlyDetectionAlerts
? alertsOptions
: patternListToOptions(pickedDataViewData.patternList)
);
}
} catch (err) {
// Nothing to do
}
setLoadingIndexPatterns(false);
} else {
setSelectedOptions(getDefaultSelectedOptionsByDataView(newSelectedDataViewId, isAlerts));
}
},
[
alertsOptions,
dispatch,
getDefaultSelectedOptionsByDataView,
isOnlyDetectionAlerts,
kibanaDataViews,
]
);
const dataViewSelectOptions = useMemo(
() =>
@ -191,6 +236,13 @@ export const usePickIndexPatterns = ({
[dataViewId, defaultDataViewId, isModified, isOnlyDetectionAlerts, kibanaDataViews]
);
useEffect(() => {
isHookAlive.current = true;
return () => {
isHookAlive.current = false;
};
}, []);
const handleOutsideClick = useCallback(() => {
setSelectedOptions(patternListToOptions(selectedPatterns));
}, [selectedPatterns]);
@ -198,6 +250,7 @@ export const usePickIndexPatterns = ({
return {
allOptions,
dataViewSelectOptions,
loadingIndexPatterns,
handleOutsideClick,
isModified,
onChangeCombo,

View file

@ -26,6 +26,8 @@ import {
} from '../../../../../../../src/plugins/data/common';
import * as i18n from './translations';
import { getBrowserFields, getDocValueFields } from './';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { getSourcererDataview } from '../sourcerer/api';
const getEsFields = memoizeOne(
(fields: IndexField[]): FieldSpec[] =>
@ -37,7 +39,13 @@ const getEsFields = memoizeOne(
(newArgs, lastArgs) => newArgs[0].length === lastArgs[0].length
);
export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) => void } => {
export const useDataView = (): {
indexFieldsSearch: (
selectedDataViewId: string,
scopeId?: SourcererScopeName,
needToBeInit?: boolean
) => void;
} => {
const { data } = useKibana().services;
const abortCtrl = useRef<Record<string, AbortController>>({});
const searchSubscription$ = useRef<Record<string, Subscription>>({});
@ -51,13 +59,29 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string)
[dispatch]
);
const indexFieldsSearch = useCallback(
(selectedDataViewId: string) => {
(
selectedDataViewId: string,
scopeId: SourcererScopeName = SourcererScopeName.default,
needToBeInit: boolean = false
) => {
const asyncSearch = async () => {
abortCtrl.current = {
...abortCtrl.current,
[selectedDataViewId]: new AbortController(),
};
setLoading({ id: selectedDataViewId, loading: true });
if (needToBeInit) {
const dataViewToUpdate = await getSourcererDataview(
selectedDataViewId,
abortCtrl.current[selectedDataViewId].signal
);
dispatch(
sourcererActions.updateSourcererDataViews({
dataView: dataViewToUpdate,
})
);
}
const subscription = data.search
.search<IndexFieldsStrategyRequest<'dataView'>, IndexFieldsStrategyResponse>(
{
@ -73,7 +97,15 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string)
next: (response) => {
if (isCompleteResponse(response)) {
const patternString = response.indicesExist.sort().join();
if (needToBeInit && scopeId) {
dispatch(
sourcererActions.setSelectedDataView({
id: scopeId,
selectedDataViewId,
selectedPatterns: response.indicesExist,
})
);
}
dispatch(
sourcererActions.setDataView({
browserFields: getBrowserFields(patternString, response.indexFields),
@ -84,15 +116,11 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string)
runtimeMappings: response.runtimeMappings,
})
);
if (searchSubscription$.current[selectedDataViewId]) {
searchSubscription$.current[selectedDataViewId].unsubscribe();
}
searchSubscription$.current[selectedDataViewId]?.unsubscribe();
} else if (isErrorResponse(response)) {
setLoading({ id: selectedDataViewId, loading: false });
addWarning(i18n.ERROR_BEAT_FIELDS);
if (searchSubscription$.current[selectedDataViewId]) {
searchSubscription$.current[selectedDataViewId].unsubscribe();
}
searchSubscription$.current[selectedDataViewId]?.unsubscribe();
}
},
error: (msg) => {
@ -104,9 +132,7 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string)
addError(msg, {
title: i18n.FAIL_BEAT_FIELDS,
});
if (searchSubscription$.current[selectedDataViewId]) {
searchSubscription$.current[selectedDataViewId].unsubscribe();
}
searchSubscription$.current[selectedDataViewId]?.unsubscribe();
},
});
searchSubscription$.current = {
@ -114,11 +140,10 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string)
[selectedDataViewId]: subscription,
};
};
if (searchSubscription$.current[selectedDataViewId] != null) {
if (searchSubscription$.current[selectedDataViewId]) {
searchSubscription$.current[selectedDataViewId].unsubscribe();
}
if (abortCtrl.current[selectedDataViewId] != null) {
if (abortCtrl.current[selectedDataViewId]) {
abortCtrl.current[selectedDataViewId].abort();
}
asyncSearch();

View file

@ -30,3 +30,15 @@ export const postSourcererDataView = async ({
body: JSON.stringify(body),
signal,
});
export const getSourcererDataview = async (
dataViewId: string,
signal?: AbortSignal
): Promise<KibanaDataView> => {
return KibanaServices.get().http.fetch<KibanaDataView>(SOURCERER_API_URL, {
method: 'GET',
query: { dataViewId },
asSystemRequest: true,
signal,
});
};

View file

@ -72,29 +72,54 @@ export const useInitSourcerer = (
getTimelineSelector(state, TimelineId.active)
);
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const { selectedDataViewId: scopeDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, scopeId)
);
const { selectedDataViewId: timelineDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, SourcererScopeName.timeline)
);
const activeDataViewIds = useMemo(
() => [...new Set([scopeDataViewId, timelineDataViewId])],
[scopeDataViewId, timelineDataViewId]
);
const {
selectedDataViewId: scopeDataViewId,
selectedPatterns,
missingPatterns,
} = useDeepEqualSelector((state) => scopeIdSelector(state, scopeId));
const {
selectedDataViewId: timelineDataViewId,
selectedPatterns: timelineSelectedPatterns,
missingPatterns: timelineMissingPatterns,
} = useDeepEqualSelector((state) => scopeIdSelector(state, SourcererScopeName.timeline));
const { indexFieldsSearch } = useDataView();
/*
* Note for future engineer:
* we changed the logic to not fetch all the index fields for every data view on the loading of the app
* because user can have a lot of them and it can slow down the loading of the app
* and maybe blow up the memory of the browser. We decided to load this data view on demand,
* we know that will only have to load this dataview on default and timeline scope.
* We will use two conditions to see if we need to fetch and initialize the dataview selected.
* First, we will make sure that we did not already fetch them by using `searchedIds`
* and then we will init them if selectedPatterns and missingPatterns are empty.
*/
const searchedIds = useRef<string[]>([]);
useEffect(
() =>
activeDataViewIds.forEach((id) => {
if (id != null && id.length > 0 && !searchedIds.current.includes(id)) {
searchedIds.current = [...searchedIds.current, id];
indexFieldsSearch(id);
}
}),
[activeDataViewIds, indexFieldsSearch]
);
useEffect(() => {
const activeDataViewIds = [...new Set([scopeDataViewId, timelineDataViewId])];
activeDataViewIds.forEach((id) => {
if (id != null && id.length > 0 && !searchedIds.current.includes(id)) {
searchedIds.current = [...searchedIds.current, id];
indexFieldsSearch(
id,
id === scopeDataViewId ? SourcererScopeName.default : SourcererScopeName.timeline,
id === scopeDataViewId
? selectedPatterns.length === 0 && missingPatterns.length === 0
: timelineDataViewId === id
? timelineMissingPatterns.length === 0 && timelineSelectedPatterns.length === 0
: false
);
}
});
}, [
indexFieldsSearch,
missingPatterns.length,
scopeDataViewId,
selectedPatterns.length,
timelineDataViewId,
timelineMissingPatterns.length,
timelineSelectedPatterns.length,
]);
// Related to timeline
useEffect(() => {
@ -334,12 +359,14 @@ export const useSourcererDataView = (
const indicesExist = useMemo(
() =>
checkIfIndicesExist({
scopeId,
signalIndexName,
patternList: sourcererDataView.patternList,
}),
[scopeId, signalIndexName, sourcererDataView]
loading || sourcererDataView.loading
? true
: checkIfIndicesExist({
scopeId,
signalIndexName,
patternList: sourcererDataView.patternList,
}),
[loading, scopeId, signalIndexName, sourcererDataView.loading, sourcererDataView.patternList]
);
return useMemo(

View file

@ -7,7 +7,7 @@
import actionCreatorFactory from 'typescript-fsa';
import { SelectedDataView, SourcererDataView, SourcererScopeName } from './model';
import { KibanaDataView, SelectedDataView, SourcererDataView, SourcererScopeName } from './model';
import { SecurityDataView } from '../../containers/sourcerer/api';
const actionCreator = actionCreatorFactory('x-pack/security_solution/local/sourcerer');
@ -43,3 +43,7 @@ export interface SelectedDataViewPayload {
shouldValidateSelectedPatterns?: boolean;
}
export const setSelectedDataView = actionCreator<SelectedDataViewPayload>('SET_SELECTED_DATA_VIEW');
export const updateSourcererDataViews = actionCreator<{
dataView: KibanaDataView;
}>('UPDATE_SOURCERER_DATA_VIEWS');

View file

@ -14,6 +14,7 @@ import {
setSignalIndexName,
setDataView,
setDataViewLoading,
updateSourcererDataViews,
} from './actions';
import { initDataView, initialSourcererState, SourcererModel, SourcererScopeName } from './model';
import { validateSelectedPatterns } from './helpers';
@ -45,6 +46,12 @@ export const sourcererReducer = reducerWithInitialState(initialSourcererState)
...dataView,
})),
}))
.case(updateSourcererDataViews, (state, { dataView }) => ({
...state,
kibanaDataViews: state.kibanaDataViews.map((dv) =>
dv.id === dataView.id ? { ...dv, ...dataView } : dv
),
}))
.case(setSourcererScopeLoading, (state, { id, loading }) => ({
...state,
sourcererScopes: {

View file

@ -11,6 +11,7 @@ import { KibanaFeatureConfig, SubFeatureConfig } from '../../features/common';
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server';
import { APP_ID, CASES_FEATURE_ID, SERVER_APP_ID } from '../common/constants';
import { savedObjectTypes } from './saved_objects';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../src/plugins/data_views/common';
export const getCasesKibanaFeature = (): KibanaFeatureConfig => ({
id: CASES_FEATURE_ID,
@ -119,7 +120,13 @@ export const getKibanaPrivilegesFeaturePrivileges = (ruleTypes: string[]): Kiban
catalogue: [APP_ID],
api: [APP_ID, 'lists-all', 'lists-read', 'rac'],
savedObject: {
all: ['alert', 'exception-list', 'exception-list-agnostic', ...savedObjectTypes],
all: [
'alert',
'exception-list',
'exception-list-agnostic',
DATA_VIEW_SAVED_OBJECT_TYPE,
...savedObjectTypes,
],
read: [],
},
alerting: {
@ -138,7 +145,12 @@ export const getKibanaPrivilegesFeaturePrivileges = (ruleTypes: string[]): Kiban
api: [APP_ID, 'lists-read', 'rac'],
savedObject: {
all: [],
read: ['exception-list', 'exception-list-agnostic', ...savedObjectTypes],
read: [
'exception-list',
'exception-list-agnostic',
DATA_VIEW_SAVED_OBJECT_TYPE,
...savedObjectTypes,
],
},
alerting: {
rule: {

View file

@ -35,8 +35,6 @@
"ml": ["all"],
"siem": ["all", "read_alerts", "crud_alerts"],
"securitySolutionCases": ["all"],
"indexPatterns": ["all"],
"savedObjectsManagement": ["all"],
"actions": ["read"],
"builtInAlerts": ["all"],
"dev_tools": ["all"]

View file

@ -39,8 +39,6 @@
"ml": ["read"],
"siem": ["all", "read_alerts", "crud_alerts"],
"securitySolutionCases": ["all"],
"indexPatterns": ["read"],
"savedObjectsManagement": ["read"],
"actions": ["read"],
"builtInAlerts": ["all"]
},

View file

@ -13,8 +13,3 @@ export * from './rule_author';
export * from './soc_manager';
export * from './t1_analyst';
export * from './t2_analyst';
// TODO: Steph/sourcerer remove from detections_role.json once we have our internal saved object client
// https://github.com/elastic/security-team/issues/1978
// "indexPatterns": ["read"],
// "savedObjectsManagement": ["read"],

View file

@ -34,8 +34,6 @@
"ml": ["all"],
"siem": ["all", "read_alerts", "crud_alerts"],
"securitySolutionCases": ["all"],
"indexPatterns": ["all"],
"savedObjectsManagement": ["all"],
"actions": ["all"],
"builtInAlerts": ["all"]
},

View file

@ -28,8 +28,6 @@
"ml": ["read"],
"siem": ["read", "read_alerts"],
"securitySolutionCases": ["read"],
"indexPatterns": ["read"],
"savedObjectsManagement": ["read"],
"actions": ["read"],
"builtInAlerts": ["read"]
},

View file

@ -37,8 +37,6 @@
"ml": ["read"],
"siem": ["all", "read_alerts", "crud_alerts"],
"securitySolutionCases": ["all"],
"indexPatterns": ["read"],
"savedObjectsManagement": ["read"],
"actions": ["read"],
"builtInAlerts": ["all"]
},

View file

@ -37,8 +37,6 @@
"ml": ["read"],
"siem": ["all", "read_alerts", "crud_alerts"],
"securitySolutionCases": ["all"],
"indexPatterns": ["all"],
"savedObjectsManagement": ["all"],
"actions": ["all"],
"builtInAlerts": ["all"]
},

View file

@ -27,8 +27,6 @@
"ml": ["read"],
"siem": ["read", "read_alerts"],
"securitySolutionCases": ["read"],
"indexPatterns": ["read"],
"savedObjectsManagement": ["read"],
"actions": ["read"],
"builtInAlerts": ["read"]
},

View file

@ -29,8 +29,6 @@
"ml": ["read"],
"siem": ["read", "read_alerts"],
"securitySolutionCases": ["read"],
"indexPatterns": ["read"],
"savedObjectsManagement": ["read"],
"actions": ["read"],
"builtInAlerts": ["read"]
},

View file

@ -57,7 +57,7 @@ const getStartServices = jest.fn().mockReturnValue([
{
data: {
indexPatterns: {
indexPatternsServiceFactory: () => ({
dataViewsServiceFactory: () => ({
getIdsWithTitle: () => new Promise((rs) => rs(mockDataViews)),
get: () => new Promise((rs) => rs(mockPattern)),
createAndSave: () => new Promise((rs) => rs(mockPattern)),
@ -73,10 +73,18 @@ const getStartServicesNotSiem = jest.fn().mockReturnValue([
{
data: {
indexPatterns: {
indexPatternsServiceFactory: () => ({
dataViewsServiceFactory: () => ({
getIdsWithTitle: () =>
new Promise((rs) => rs(mockDataViews.filter((v) => v.id !== mockPattern.id))),
get: () => new Promise((rs) => rs(mockPattern)),
get: (id: string) =>
new Promise((rs) =>
id === mockPattern.id
? rs(null)
: rs({
id: 'dataview-lambda',
title: 'fun-*,dog-*,cat-*',
})
),
createAndSave: () => new Promise((rs) => rs(mockPattern)),
updateSavedObject: () => new Promise((rs) => rs(mockPattern)),
}),
@ -95,12 +103,10 @@ const mockDataViewsTransformed = {
kibanaDataViews: [
{
id: 'metrics-*',
patternList: ['metrics-*'],
title: 'metrics-*',
},
{
id: 'logs-*',
patternList: ['logs-*'],
title: 'logs-*',
},
{
@ -112,58 +118,87 @@ const mockDataViewsTransformed = {
],
};
export const getSourcererRequest = (patternList: string[]) =>
requestMock.create({
method: 'post',
path: SOURCERER_API_URL,
body: { patternList },
});
describe('sourcerer route', () => {
let server: ReturnType<typeof serverMock.create>;
let { context } = requestContextMock.createTools();
beforeEach(() => {
server = serverMock.create();
({ context } = requestContextMock.createTools());
});
describe('post', () => {
const getSourcererRequest = (patternList: string[]) =>
requestMock.create({
method: 'post',
path: SOURCERER_API_URL,
body: { patternList },
});
test('returns sourcerer formatted Data Views when SIEM Data View does NOT exist', async () => {
createSourcererDataViewRoute(server.router, getStartServicesNotSiem);
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockDataViewsTransformed);
});
describe('functional tests', () => {
beforeEach(() => {
server = serverMock.create();
({ context } = requestContextMock.createTools());
});
test('returns sourcerer formatted Data Views when SIEM Data View does NOT exist', async () => {
createSourcererDataViewRoute(server.router, getStartServicesNotSiem);
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockDataViewsTransformed);
});
test('returns sourcerer formatted Data Views when SIEM Data View exists', async () => {
createSourcererDataViewRoute(server.router, getStartServices);
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockDataViewsTransformed);
});
test('returns sourcerer formatted Data Views when SIEM Data View does NOT exist but has been created in the mean time', async () => {
const getMock = jest.fn();
getMock.mockResolvedValueOnce(null);
getMock.mockResolvedValueOnce(mockPattern);
const getStartServicesSpecial = jest.fn().mockResolvedValue([
null,
{
data: {
indexPatterns: {
dataViewsServiceFactory: () => ({
getIdsWithTitle: () =>
new Promise((rs) => rs(mockDataViews.filter((v) => v.id !== mockPattern.id))),
get: getMock,
createAndSave: jest.fn().mockRejectedValue({ statusCode: 409 }),
updateSavedObject: () => new Promise((rs, rj) => rj(new Error('error'))),
}),
},
},
},
] as unknown) as StartServicesAccessor<StartPlugins>;
createSourcererDataViewRoute(server.router, getStartServicesSpecial);
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockDataViewsTransformed);
});
test('returns sourcerer formatted Data Views when SIEM Data View exists and patternList input is changed', async () => {
createSourcererDataViewRoute(server.router, getStartServices);
mockPatternList.shift();
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
defaultDataView: {
id: 'security-solution',
patternList: ['traces-apm*', 'auditbeat-*'],
title:
'traces-apm*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,winlogbeat-*,ml_host_risk_score_*,.siem-signals-default',
},
kibanaDataViews: [
mockDataViewsTransformed.kibanaDataViews[0],
mockDataViewsTransformed.kibanaDataViews[1],
{
id: 'security-solution',
patternList: ['traces-apm*', 'auditbeat-*'],
title:
'traces-apm*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,winlogbeat-*,ml_host_risk_score_*,.siem-signals-default',
},
],
test('returns sourcerer formatted Data Views when SIEM Data View exists', async () => {
createSourcererDataViewRoute(server.router, getStartServices);
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockDataViewsTransformed);
});
test('returns sourcerer formatted Data Views when SIEM Data View exists and patternList input is changed', async () => {
createSourcererDataViewRoute(server.router, getStartServices);
mockPatternList.shift();
const response = await server.inject(getSourcererRequest(mockPatternList), context);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
defaultDataView: {
id: 'security-solution',
patternList: ['.siem-signals-default', 'auditbeat-*'],
title:
'.siem-signals-default,auditbeat-*,endgame-*,filebeat-*,logs-*,ml_host_risk_score_*,packetbeat-*,traces-apm*,winlogbeat-*',
},
kibanaDataViews: [
mockDataViewsTransformed.kibanaDataViews[0],
mockDataViewsTransformed.kibanaDataViews[1],
{
id: 'security-solution',
patternList: ['.siem-signals-default', 'auditbeat-*'],
title:
'.siem-signals-default,auditbeat-*,endgame-*,filebeat-*,logs-*,ml_host_risk_score_*,packetbeat-*,traces-apm*,winlogbeat-*',
},
],
});
});
});
});
});

View file

@ -6,14 +6,19 @@
*/
import { transformError } from '@kbn/securitysolution-es-utils';
import { StartServicesAccessor } from 'kibana/server';
import type { SecuritySolutionPluginRouter } from '../../../types';
import type { ElasticsearchClient, StartServicesAccessor } from 'kibana/server';
import type {
DataView,
DataViewListItem,
} from '../../../../../../../src/plugins/data_views/common';
import { DEFAULT_TIME_FIELD, SOURCERER_API_URL } from '../../../../common/constants';
import { buildSiemResponse } from '../../detection_engine/routes/utils';
import type { SecuritySolutionPluginRouter } from '../../../types';
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
import { sourcererSchema } from './schema';
import { StartPlugins } from '../../../plugin';
import type { StartPlugins } from '../../../plugin';
import { buildSiemResponse } from '../../detection_engine/routes/utils';
import { findExistingIndices } from './helpers';
import { sourcererDataViewSchema, sourcererSchema } from './schema';
export const createSourcererDataViewRoute = (
router: SecuritySolutionPluginRouter,
@ -26,6 +31,7 @@ export const createSourcererDataViewRoute = (
body: buildRouteValidation(sourcererSchema),
},
options: {
authRequired: true,
tags: ['access:securitySolution'],
},
},
@ -33,6 +39,7 @@ export const createSourcererDataViewRoute = (
const siemResponse = buildSiemResponse(response);
const siemClient = context.securitySolution?.getAppClient();
const dataViewId = siemClient.getSourcererDataViewId();
try {
const [
,
@ -40,71 +47,70 @@ export const createSourcererDataViewRoute = (
data: { indexPatterns },
},
] = await getStartServices();
const dataViewService = await indexPatterns.indexPatternsServiceFactory(
const dataViewService = await indexPatterns.dataViewsServiceFactory(
context.core.savedObjects.client,
context.core.elasticsearch.client.asInternalUser,
request
context.core.elasticsearch.client.asCurrentUser,
request,
true
);
let allDataViews = await dataViewService.getIdsWithTitle();
const { patternList } = request.body;
const siemDataView = allDataViews.find((v) => v.id === dataViewId);
const patternListAsTitle = patternList.join();
if (siemDataView == null) {
const defaultDataView = await dataViewService.createAndSave({
allowNoIndex: true,
id: dataViewId,
title: patternListAsTitle,
timeFieldName: DEFAULT_TIME_FIELD,
});
// ?? dataViewId -> type thing here, should never happen
allDataViews.push({ ...defaultDataView, id: defaultDataView.id ?? dataViewId });
} else if (patternListAsTitle !== siemDataView.title) {
const defaultDataView = { ...siemDataView, id: siemDataView.id ?? '' };
const wholeDataView = await dataViewService.get(defaultDataView.id);
wholeDataView.title = patternListAsTitle;
let didUpdate = true;
await dataViewService.updateSavedObject(wholeDataView).catch((err) => {
const error = transformError(err);
if (error.statusCode === 403) {
didUpdate = false;
// user doesnt have permissions to update, use existing pattern
wholeDataView.title = defaultDataView.title;
return;
}
let allDataViews: DataViewListItem[] = await dataViewService.getIdsWithTitle();
let siemDataView = null;
try {
siemDataView = await dataViewService.get(dataViewId);
} catch (err) {
const error = transformError(err);
// Do nothing if statusCode === 404 because we expect that the security dataview does not exist
if (error.statusCode !== 404) {
throw err;
});
// update the data view in allDataViews
if (didUpdate) {
allDataViews = allDataViews.map((v) =>
v.id === dataViewId ? { ...v, title: patternListAsTitle } : v
);
}
}
const patternLists: string[][] = allDataViews.map(({ title }) => title.split(','));
const activePatternBools: boolean[][] = await Promise.all(
patternLists.map((pl) =>
findExistingIndices(pl, context.core.elasticsearch.client.asCurrentUser)
)
);
const { patternList } = request.body;
const patternListAsTitle = patternList.sort().join();
const siemDataViewTitle = siemDataView ? siemDataView.title.split(',').sort().join() : '';
if (siemDataView == null) {
try {
siemDataView = await dataViewService.createAndSave({
allowNoIndex: true,
id: dataViewId,
title: patternListAsTitle,
timeFieldName: DEFAULT_TIME_FIELD,
});
} catch (err) {
const error = transformError(err);
if (err.name === 'DuplicateDataViewError' || error.statusCode === 409) {
siemDataView = await dataViewService.get(dataViewId);
} else {
throw error;
}
}
} else if (patternListAsTitle !== siemDataViewTitle) {
siemDataView.title = patternListAsTitle;
await dataViewService.updateSavedObject(siemDataView);
}
const activePatternLists = patternLists.map((pl, i) =>
// also remove duplicates from active
pl.filter((pattern, j, self) => self.indexOf(pattern) === j && activePatternBools[i][j])
);
if (allDataViews.some((dv) => dv.id === dataViewId)) {
allDataViews = allDataViews.map((v) =>
v.id === dataViewId ? { ...v, title: patternListAsTitle } : v
);
} else {
allDataViews.push({ ...siemDataView, id: siemDataView.id ?? dataViewId });
}
const kibanaDataViews = allDataViews.map((kip, i) => ({
...kip,
patternList: activePatternLists[i],
}));
const body = {
defaultDataView: kibanaDataViews.find((p) => p.id === dataViewId) ?? {},
kibanaDataViews,
};
return response.ok({ body });
const defaultDataView = await buildSourcererDataView(
siemDataView,
context.core.elasticsearch.client.asCurrentUser
);
return response.ok({
body: {
defaultDataView,
kibanaDataViews: allDataViews.map((dv) =>
dv.id === dataViewId ? defaultDataView : dv
),
},
});
} catch (err) {
const error = transformError(err);
return siemResponse.error({
@ -118,3 +124,72 @@ export const createSourcererDataViewRoute = (
}
);
};
export const getSourcererDataViewRoute = (
router: SecuritySolutionPluginRouter,
getStartServices: StartServicesAccessor<StartPlugins>
) => {
router.get(
{
path: SOURCERER_API_URL,
validate: {
query: buildRouteValidation(sourcererDataViewSchema),
},
options: {
tags: ['access:securitySolution'],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const { dataViewId } = request.query;
try {
const [
,
{
data: { indexPatterns },
},
] = await getStartServices();
const dataViewService = await indexPatterns.dataViewsServiceFactory(
context.core.savedObjects.client,
context.core.elasticsearch.client.asCurrentUser,
request,
true
);
const siemDataView = await dataViewService.get(dataViewId);
const kibanaDataView = siemDataView
? await buildSourcererDataView(
siemDataView,
context.core.elasticsearch.client.asCurrentUser
)
: {};
return response.ok({
body: kibanaDataView,
});
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body:
error.statusCode === 403
? 'Users with write permissions need to access the Elastic Security app to initialize the app source data.'
: error.message,
statusCode: error.statusCode,
});
}
}
);
};
const buildSourcererDataView = async (
dataView: DataView,
clientAsCurrentUser: ElasticsearchClient
) => {
const patternList = dataView.title.split(',');
const activePatternBools: boolean[] = await findExistingIndices(patternList, clientAsCurrentUser);
const activePatternLists: string[] = patternList.filter(
(pattern, j, self) => self.indexOf(pattern) === j && activePatternBools[j]
);
return { ...dataView, patternList: activePatternLists };
};

View file

@ -10,3 +10,7 @@ import * as t from 'io-ts';
export const sourcererSchema = t.type({
patternList: t.array(t.string),
});
export const sourcererDataViewSchema = t.type({
dataViewId: t.string,
});

View file

@ -67,7 +67,7 @@ import {
} from '../lib/detection_engine/rule_types/types';
// eslint-disable-next-line no-restricted-imports
import { legacyCreateLegacyNotificationRoute } from '../lib/detection_engine/routes/rules/legacy_create_legacy_notification';
import { createSourcererDataViewRoute } from '../lib/sourcerer/routes';
import { createSourcererDataViewRoute, getSourcererDataViewRoute } from '../lib/sourcerer/routes';
export const initRoutes = (
router: SecuritySolutionPluginRouter,
@ -160,4 +160,5 @@ export const initRoutes = (
// Sourcerer API to generate default pattern
createSourcererDataViewRoute(router, getStartServices);
getSourcererDataViewRoute(router, getStartServices);
};

View file

@ -817,7 +817,7 @@ describe('Fields Provider', () => {
{
data: {
indexPatterns: {
indexPatternsServiceFactory: () => ({
dataViewsServiceFactory: () => ({
get: jest.fn().mockReturnValue(mockPattern),
}),
},

View file

@ -72,7 +72,7 @@ export const findExistingIndices = async (
export const requestIndexFieldSearch = async (
request: IndexFieldsStrategyRequest<'indices' | 'dataView'>,
{ savedObjectsClient, esClient }: SearchStrategyDependencies,
{ savedObjectsClient, esClient, request: kRequest }: SearchStrategyDependencies,
beatFields: BeatFields,
getStartServices: StartServicesAccessor<StartPlugins>
): Promise<IndexFieldsStrategyResponse> => {
@ -87,9 +87,12 @@ export const requestIndexFieldSearch = async (
data: { indexPatterns },
},
] = await getStartServices();
const dataViewService = await indexPatterns.indexPatternsServiceFactory(
const dataViewService = await indexPatterns.dataViewsServiceFactory(
savedObjectsClient,
esClient.asCurrentUser
esClient.asCurrentUser,
kRequest,
true
);
let indicesExist: string[] = [];
@ -119,6 +122,7 @@ export const requestIndexFieldSearch = async (
(acc: string[], doesIndexExist, i) => (doesIndexExist ? [...acc, patternList[i]] : acc),
[]
);
if (!request.onlyCheckIfIndicesExist) {
const dataViewSpec = dataView.toSpec();
const fieldDescriptor = [Object.values(dataViewSpec.fields ?? {})];