diff --git a/cypress/integration/query_level_monitor_spec.js b/cypress/integration/query_level_monitor_spec.js index 8995c29d5..0dca7b3a9 100644 --- a/cypress/integration/query_level_monitor_spec.js +++ b/cypress/integration/query_level_monitor_spec.js @@ -155,6 +155,78 @@ describe('Query-Level Monitors', () => { }); }); + if (Cypress.env('security_enabled')) { + describe('can be created with backend roles', () => { + before(() => { + cy.deleteAllMonitors(); + }); + + it('by extraction query', () => { + // mock enable backend roles + cy.intercept('GET', '/api/alerting/_settings', { + statusCode: 200, + body: { + ok: true, + resp: { + persistent: { + plugins: { + alerting: { + filter_by_backend_roles: 'true', + }, + }, + }, + transient: {}, + }, + }, + }); + + // Confirm we loaded empty monitor list + cy.contains('There are no existing monitors'); + + // Route us to create monitor page + cy.contains('Create monitor').click({ force: true }); + + // Select the Query-Level Monitor type + cy.get('[data-test-subj="queryLevelMonitorRadioCard"]').click(); + + // Select extraction query for method of definition + cy.get('[data-test-subj="extractionQueryEditorRadioCard"]').click(); + + // Wait for input to load and then type in the monitor name + cy.get('input[name="name"]').type(SAMPLE_MONITOR, { force: true }); + + // Wait for input to load and then type in the index name + cy.get('#index').type('*', { force: true }); + + // Wait for input to load and then type in the role + cy.get('#roles').click(); + cy.contains('admin').click({ force: true }); + cy.get('#roles').blur(); + + // Add a trigger + cy.contains('Add trigger').click({ force: true }); + + // Type in the trigger name + cy.get('input[name="triggerDefinitions[0].name"]').type(SAMPLE_TRIGGER, { force: true }); + + // Click the create button + cy.get('button').contains('Create').click({ force: true }); + + // Confirm we can see only one row in the trigger list by checking element + cy.contains('This table contains 1 row'); + + // Confirm we can see the new trigger + cy.contains(SAMPLE_TRIGGER); + + // Go back to the Monitors list + cy.get('a').contains('Monitors').click({ force: true }); + + // Confirm we can see the created monitor in the list + cy.contains(SAMPLE_MONITOR); + }); + }); + } + describe('can be updated', () => { beforeEach(() => { cy.deleteAllMonitors(); diff --git a/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap b/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap index 3c2c05e33..e6d2ef62b 100644 --- a/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap +++ b/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap @@ -56,6 +56,7 @@ exports[`AddAlertingMonitor renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], diff --git a/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap b/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap index 18c958869..3eded366f 100644 --- a/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap +++ b/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap @@ -52,6 +52,7 @@ exports[`AnomalyDetectors renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], @@ -126,6 +127,7 @@ exports[`AnomalyDetectors renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], @@ -261,6 +263,7 @@ exports[`AnomalyDetectors renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], @@ -354,6 +357,7 @@ exports[`AnomalyDetectors renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], @@ -495,6 +499,7 @@ exports[`AnomalyDetectors renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], @@ -588,6 +593,7 @@ exports[`AnomalyDetectors renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js index 353255ada..915f8894a 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js @@ -28,6 +28,8 @@ import { getPerformanceModal, RECOMMENDED_DURATION, } from '../../components/QueryPerformance/QueryPerformance'; +import MonitorSecurity from '../MonitorSecurity'; +import { FILTER_BY_BACKEND_ROLES_SETTING_PATH } from './utils/constants'; export default class CreateMonitor extends Component { static defaultProps = { @@ -57,16 +59,47 @@ export default class CreateMonitor extends Component { triggerToEdit, createModalOpen: false, formikBag: undefined, + filterByBackendRolesEnabled: false, }; this.onCancel = this.onCancel.bind(this); this.onSubmit = this.onSubmit.bind(this); this.evaluateSubmission = this.evaluateSubmission.bind(this); + this.getSettings = this.getSettings.bind(this); + } + + async getSettings() { + try { + const { httpClient } = this.props; + const response = await httpClient.get('../api/alerting/_settings'); + if (response.ok) { + const { defaults, transient, persistent } = response.resp; + let filterByBackendRolesEnabled = _.get( + // If present, take the 'transient' setting. + transient, + FILTER_BY_BACKEND_ROLES_SETTING_PATH, + // Else take the 'persistent' setting. + _.get( + persistent, + FILTER_BY_BACKEND_ROLES_SETTING_PATH, + // Else take the 'default' setting. + _.get(defaults, FILTER_BY_BACKEND_ROLES_SETTING_PATH, false) + ) + ); + // Boolean settings are returned as strings (e.g., `"true"`, and `"false"`). Constructing boolean value from the string. + if (typeof filterByBackendRolesEnabled === 'string') + filterByBackendRolesEnabled = JSON.parse(filterByBackendRolesEnabled); + this.setState({ filterByBackendRolesEnabled: filterByBackendRolesEnabled }); + } + } catch (e) { + console.log('Error while retrieving settings', e); + } } componentDidMount() { const { httpClient } = this.props; + this.getSettings(); const updatePlugins = async () => { const newPlugins = await getPlugins(httpClient); this.setState({ plugins: newPlugins }); @@ -162,7 +195,7 @@ export default class CreateMonitor extends Component { isDarkMode, notificationService, } = this.props; - const { createModalOpen, initialValues, plugins } = this.state; + const { createModalOpen, initialValues, plugins, filterByBackendRolesEnabled } = this.state; return (
@@ -207,6 +240,13 @@ export default class CreateMonitor extends Component { ) : null} + {isComposite && filterByBackendRolesEnabled ? ( + <> + + + + ) : null} + {values.searchType !== SEARCH_TYPE.AD && diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap b/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap index 598937daf..1917b30ba 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap +++ b/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap @@ -60,6 +60,7 @@ exports[`CreateMonitor renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/__snapshots__/formikToMonitor.test.js.snap b/public/pages/CreateMonitor/containers/CreateMonitor/utils/__snapshots__/formikToMonitor.test.js.snap index 1456896ef..a02867010 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/__snapshots__/formikToMonitor.test.js.snap +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/__snapshots__/formikToMonitor.test.js.snap @@ -209,6 +209,90 @@ Object { } `; +exports[`formikToMonitor can build monitor with roles 1`] = ` +Object { + "enabled": false, + "inputs": Array [ + Object { + "search": Object { + "indices": Array [ + "index1", + "index2", + ], + "query": Object { + "aggregations": Object {}, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "": Object { + "format": "epoch_millis", + "gte": "{{period_end}}||-1h", + "lte": "{{period_end}}", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + }, + }, + ], + "monitor_type": "query_level_monitor", + "name": "random_name", + "rbac_roles": Array [ + "test_bk_role_1", + "test_bk_role_2", + ], + "schedule": Object { + "period": Object { + "interval": 1, + "unit": "MINUTES", + }, + }, + "triggers": Array [], + "type": "monitor", + "ui_metadata": Object { + "monitor_type": "query_level_monitor", + "schedule": Object { + "cronExpression": "0 */1 * * *", + "daily": 0, + "frequency": "interval", + "monthly": Object { + "day": 1, + "type": "day", + }, + "period": Object { + "interval": 1, + "unit": "MINUTES", + }, + "timezone": "America/Los_Angeles", + "weekly": Object { + "fri": false, + "mon": false, + "sat": false, + "sun": false, + "thur": false, + "tue": false, + "wed": false, + }, + }, + "search": Object { + "aggregations": Array [], + "bucketUnitOfTime": "h", + "bucketValue": 1, + "filters": Array [], + "groupBy": Array [], + "searchType": "graph", + "timeField": "", + }, + }, +} +`; + exports[`formikToQuery can build extraction query 1`] = ` Object { "query": Object { @@ -240,6 +324,13 @@ Object { } `; +exports[`formikToRoles can build roles 1`] = ` +Array [ + "test_bk_role_1", + "test_bk_role_2", +] +`; + exports[`formikToUiGraphQuery can build ui graph query 1`] = ` Object { "aggregations": Object { diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js index 39c2de490..40dd4efb4 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js @@ -78,6 +78,7 @@ export const FORMIK_INITIAL_VALUES = { associatedMonitorsList: [], associatedMonitorsEditor: '', preventVisualEditor: false, + roles: [], }; export const FORMIK_INITIAL_AGG_VALUES = { @@ -112,6 +113,8 @@ export const DEFAULT_DOCUMENT_LEVEL_QUERY = JSON.stringify( export const DEFAULT_COMPOSITE_AGG_SIZE = 50; +export const FILTER_BY_BACKEND_ROLES_SETTING_PATH = 'plugins.alerting.filter_by_backend_roles'; + export const METRIC_TOOLTIP_TEXT = 'Extracted statistics such as simple calculations of data.'; export const TIME_RANGE_TOOLTIP_TEXT = 'The time frame of data the plugin should monitor.'; export const FILTERS_TOOLTIP_TEXT = diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js index 4619c08b2..87c47a5fa 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js @@ -63,6 +63,7 @@ export function formikToMonitor(values) { monitor_type: values.monitor_type, ...monitorUiMetadata(), }, + ...(formikToRoles(values).length && { rbac_roles: formikToRoles(values) }), }; } @@ -79,6 +80,7 @@ export function formikToMonitor(values) { monitor_type: values.monitor_type, ...monitorUiMetadata(), }, + ...(formikToRoles(values).length && { rbac_roles: formikToRoles(values) }), }; } @@ -213,6 +215,10 @@ export function formikToIndices(values) { return values.index.map(({ label, value }) => (hasRemoteClusters ? value : label)); } +export function formikToRoles(values) { + return values.roles.map(({ label }) => label); +} + export function formikToQuery(values) { const isGraph = values.searchType === SEARCH_TYPE.GRAPH; return isGraph ? formikToGraphQuery(values) : formikToExtractionQuery(values); diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.test.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.test.js index 6c40965ef..72dcfc27f 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.test.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.test.js @@ -20,6 +20,7 @@ import { formikToAd, formikToInputs, formikToClusterMetricsInput, + formikToRoles, } from './formikToMonitor'; import { FORMIK_INITIAL_VALUES } from './constants'; @@ -32,15 +33,23 @@ jest.mock('moment-timezone', () => { }); describe('formikToMonitor', () => { - const formikValues = _.cloneDeep(FORMIK_INITIAL_VALUES); - formikValues.name = 'random_name'; - formikValues.disabled = true; - formikValues.index = [{ label: 'index1' }, { label: 'index2' }]; - formikValues.fieldName = [{ label: 'bytes' }]; - formikValues.timezone = [{ label: 'America/Los_Angeles' }]; + let formikValues; + beforeEach(() => { + formikValues = _.cloneDeep(FORMIK_INITIAL_VALUES); + formikValues.name = 'random_name'; + formikValues.disabled = true; + formikValues.index = [{ label: 'index1' }, { label: 'index2' }]; + formikValues.fieldName = [{ label: 'bytes' }]; + formikValues.timezone = [{ label: 'America/Los_Angeles' }]; + formikValues.roles = []; + }); test('can build monitor', () => { expect(formikToMonitor(formikValues)).toMatchSnapshot(); }); + test('can build monitor with roles', () => { + formikValues.roles = [{ label: 'test_bk_role_1' }, { label: 'test_bk_role_2' }]; + expect(formikToMonitor(formikValues)).toMatchSnapshot(); + }); }); describe('formikToInputs', () => { @@ -108,6 +117,14 @@ describe('formikToIndices', () => { }); }); +describe('formikToRoles', () => { + test('can build roles', () => { + const formikValues = _.cloneDeep(FORMIK_INITIAL_VALUES); + formikValues.roles = [{ label: 'test_bk_role_1' }, { label: 'test_bk_role_2' }]; + expect(formikToRoles(formikValues)).toMatchSnapshot(); + }); +}); + describe('formikToQuery', () => { const formikValues = _.cloneDeep(FORMIK_INITIAL_VALUES); diff --git a/public/pages/CreateMonitor/containers/DefineMonitor/DefineMonitor.js b/public/pages/CreateMonitor/containers/DefineMonitor/DefineMonitor.js index 6a2af98d4..b6b4b1f0b 100644 --- a/public/pages/CreateMonitor/containers/DefineMonitor/DefineMonitor.js +++ b/public/pages/CreateMonitor/containers/DefineMonitor/DefineMonitor.js @@ -27,13 +27,17 @@ import { buildRequest } from './utils/searchRequests'; import { SEARCH_TYPE, OS_AD_PLUGIN, MONITOR_TYPE } from '../../../../utils/constants'; import { backendErrorNotification } from '../../../../utils/helpers'; import DataSource from '../DataSource'; +import MonitorSecurity from '../MonitorSecurity'; import { buildClusterMetricsRequest, getApiType, getApiTypesRequiringPathParams, } from '../../components/ClusterMetricsMonitor/utils/clusterMetricsMonitorHelpers'; import ClusterMetricsMonitor from '../../components/ClusterMetricsMonitor'; -import { FORMIK_INITIAL_VALUES } from '../CreateMonitor/utils/constants'; +import { + FORMIK_INITIAL_VALUES, + FILTER_BY_BACKEND_ROLES_SETTING_PATH, +} from '../CreateMonitor/utils/constants'; import { API_TYPES } from '../../components/ClusterMetricsMonitor/utils/clusterMetricsMonitorConstants'; import ConfigureDocumentLevelQueries from '../../components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueries'; import FindingsDashboard from '../../../Dashboard/containers/FindingsDashboard'; @@ -78,6 +82,7 @@ class DefineMonitor extends Component { PanelComponent: props.flyoutMode ? ({ children }) => <>{children} : ContentPanel, remoteMonitoringEnabled: false, canCallGetRemoteIndexes: false, + filterByBackendRolesEnabled: false, }; this.renderGraph = this.renderGraph.bind(this); @@ -208,6 +213,24 @@ class DefineMonitor extends Component { if (typeof remoteMonitoringEnabled === 'string') { remoteMonitoringEnabled = JSON.parse(remoteMonitoringEnabled); } + this.setState({ remoteMonitoringEnabled: remoteMonitoringEnabled }); + + let filterByBackendRolesEnabled = _.get( + // If present, take the 'transient' setting. + transient, + FILTER_BY_BACKEND_ROLES_SETTING_PATH, + // Else take the 'persistent' setting. + _.get( + persistent, + FILTER_BY_BACKEND_ROLES_SETTING_PATH, + // Else take the 'default' setting. + _.get(defaults, FILTER_BY_BACKEND_ROLES_SETTING_PATH, false) + ) + ); + // Boolean settings are returned as strings (e.g., `"true"`, and `"false"`). Constructing boolean value from the string. + if (typeof filterByBackendRolesEnabled === 'string') + filterByBackendRolesEnabled = JSON.parse(filterByBackendRolesEnabled); + this.setState({ filterByBackendRolesEnabled: filterByBackendRolesEnabled }); } } catch (e) { console.log('Error while retrieving settings:', e); @@ -679,7 +702,7 @@ class DefineMonitor extends Component { isDarkMode, flyoutMode, } = this.props; - const { dataTypes, PanelComponent, canCallGetRemoteIndexes, remoteMonitoringEnabled } = + const { dataTypes, PanelComponent, canCallGetRemoteIndexes, remoteMonitoringEnabled, filterByBackendRolesEnabled } = this.state; const monitorContent = this.getMonitorContent(); const { searchType } = this.props.values; @@ -708,6 +731,12 @@ class DefineMonitor extends Component {
)} + {filterByBackendRolesEnabled ? ( +
+ + +
+ ) : null} ({ + label: role, + role, + })); + return _.sortBy(roles, 'label'); + } + return []; + } catch (err) { + console.error(err); + return []; + } + } + + async onFetch(query) { + this.setState({ isLoading: true }); + const roles = await this.handleQueryRoles(query); + createReasonableWait(() => { + // If the search changed, discard this state + if (query !== this.lastQuery) { + return; + } + this.setState({ roles, isLoading: false }); + }); + } + + render() { + const { roles, isLoading } = this.state; + + const visibleOptions = [ + { + label: 'Backend roles', + options: roles, + }, + ]; + + return ( + + + Backend roles + - optional + + + Specify role-based access control (RBAC) backend roles.{' '} + + Learn more + + + + ), + style: { paddingLeft: '10px' }, + }} + inputProps={{ + placeholder: 'Select backend roles', + async: true, + isLoading, + options: visibleOptions, + onBlur: (e, field, form) => { + form.setFieldTouched('roles', true); + }, + onChange: (options, field, form) => { + form.setFieldValue('roles', options); + }, + onSearchChange: this.onSearchChange, + isClearable: true, + singleSelection: false, + 'data-test-subj': 'rolesComboBox', + }} + /> + ); + } +} + +MonitorRoles.propTypes = propTypes; + +export default MonitorRoles; diff --git a/public/pages/CreateMonitor/containers/MonitorRoles/MonitorRoles.test.js b/public/pages/CreateMonitor/containers/MonitorRoles/MonitorRoles.test.js new file mode 100644 index 000000000..bca3d1197 --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorRoles/MonitorRoles.test.js @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Formik } from 'formik'; +import { mount } from 'enzyme'; + +import { FORMIK_INITIAL_VALUES } from '../CreateMonitor/utils/constants'; +import MonitorRoles from './MonitorRoles'; +import * as helpers from '../MonitorIndex//utils/helpers'; +import { httpClientMock } from '../../../../../test/mocks'; + +helpers.createReasonableWait = jest.fn((cb) => cb()); +httpClientMock.post.mockResolvedValue({ ok: true, resp: [] }); + +// Enzyme's change event is synchronous and Formik's handlers are asynchronous +// https://github.com/formium/formik/issues/937, https://www.benmvp.com/blog/asynchronous-testing-with-enzyme-react-jest/ +const runAllPromises = () => new Promise(setImmediate); + +function getMountWrapper(customProps = {}) { + return mount( + + {() => } + + ); +} + +describe('MonitorRoles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('renders', () => { + const wrapper = getMountWrapper(); + expect(wrapper).toMatchSnapshot(); + }); + + test('calls onSearchChange when changing input value', () => { + const onSearchChange = jest.spyOn(MonitorRoles.prototype, 'onSearchChange'); + const wrapper = getMountWrapper(); + wrapper + .find('[data-test-subj="comboBoxSearchInput"]') + .hostNodes() + .simulate('change', { target: { value: 'random-role' } }); + + expect(onSearchChange).toHaveBeenCalled(); + expect(onSearchChange).toHaveBeenCalledWith('random-role', false); + }); + + test('searches space normalizes value', () => { + const wrapper = getMountWrapper(); + + wrapper + .find('[data-test-subj="comboBoxSearchInput"]') + .hostNodes() + .simulate('change', { target: { value: ' ' } }) + .simulate('keyDown', { key: 'Enter' }); + + expect(wrapper.find('.euiComboBoxPill')).toHaveLength(0); + }); + + test('returns empty array for data.ok = false', async () => { + httpClientMock.post.mockResolvedValue({ ok: false }); + const wrapper = getMountWrapper(); + + expect(await wrapper.find(MonitorRoles).instance().handleQueryRoles('random')).toEqual([]); + expect(await wrapper.find(MonitorRoles).instance().handleQueryRoles('random')).toEqual([]); + }); + + test('returns roles', async () => { + httpClientMock.post.mockResolvedValue({ + ok: true, + resp: ['logstash'], + }); + const wrapper = getMountWrapper(); + + expect(await wrapper.find(MonitorRoles).instance().handleQueryRoles('l')).toEqual([ + { label: 'logstash', role: 'logstash' }, + ]); + }); + + test('sets option when calling onCreateOption', async () => { + httpClientMock.post.mockResolvedValue({ + ok: true, + resp: ['logstash'], + }); + const wrapper = getMountWrapper(); + + wrapper + .find('[data-test-subj="comboBoxSearchInput"]') + .hostNodes() + .simulate('change', { target: { value: 'logstash' } }); + + await runAllPromises(); + + wrapper + .find('[data-test-subj="comboBoxInput"]') + .hostNodes() + .simulate('keyDown', { key: 'ArrowDown' }) + .simulate('keyDown', { key: 'Enter' }); + + // Validate the specific role is in the input field + expect(wrapper.find('[data-test-subj="comboBoxInput"]').text()).toEqual('logstashEuiIconMock'); + }); +}); diff --git a/public/pages/CreateMonitor/containers/MonitorRoles/__snapshots__/MonitorRoles.test.js.snap b/public/pages/CreateMonitor/containers/MonitorRoles/__snapshots__/MonitorRoles.test.js.snap new file mode 100644 index 000000000..f59d30f7e --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorRoles/__snapshots__/MonitorRoles.test.js.snap @@ -0,0 +1,964 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorRoles renders 1`] = ` + + + + + + Backend roles + + + - optional + + + + Specify role-based access control (RBAC) backend roles. + + + Learn more + + + , + "style": Object { + "paddingLeft": "10px", + }, + } + } + > + + + + + + Backend roles + + + - optional + + + + Specify role-based access control (RBAC) backend roles. + + + Learn more + + + , + "style": Object { + "paddingLeft": "10px", + }, + } + } + > + + + + Backend roles + + + - optional + + + + Specify role-based access control (RBAC) backend roles. + + + Learn more + + + + } + labelType="label" + style={ + Object { + "paddingLeft": "10px", + } + } + > +
+
+ + + +
+
+ + +
+ + +
+
+
+

+ Select backend roles +

+ +
+ +
+
+ +
+ +
+ + + + + + +
+
+
+
+ + +
+ + +
+
+ + + + + + + +`; diff --git a/public/pages/CreateMonitor/containers/MonitorRoles/index.js b/public/pages/CreateMonitor/containers/MonitorRoles/index.js new file mode 100644 index 000000000..ebc73abc5 --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorRoles/index.js @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import MonitorRoles from './MonitorRoles'; + +export default MonitorRoles; diff --git a/public/pages/CreateMonitor/containers/MonitorSecurity/MonitorSecurity.js b/public/pages/CreateMonitor/containers/MonitorSecurity/MonitorSecurity.js new file mode 100644 index 000000000..97bb0c34e --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorSecurity/MonitorSecurity.js @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { EuiSpacer } from '@elastic/eui'; +import MonitorRoles from '../MonitorRoles'; +import ContentPanel from '../../../../components/ContentPanel'; + +const propTypes = { + httpClient: PropTypes.object.isRequired, + isMinimal: PropTypes.bool, +}; +const defaultProps = { + isMinimal: false, +}; +class MonitorSecurity extends Component { + constructor(props) { + super(props); + + this.state = { + response: null, + }; + } + + render() { + const { isMinimal } = this.props; + const monitorRoleDisplay = ( + <> + + + ); + + if (isMinimal) { + return { monitorRoleDisplay }; + } + return ( + + {monitorRoleDisplay} + + ); + } +} + +MonitorSecurity.propTypes = propTypes; +MonitorSecurity.defaultProps = defaultProps; + +export default MonitorSecurity; diff --git a/public/pages/CreateMonitor/containers/MonitorSecurity/MonitorSecurity.test.js b/public/pages/CreateMonitor/containers/MonitorSecurity/MonitorSecurity.test.js new file mode 100644 index 000000000..acdb40782 --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorSecurity/MonitorSecurity.test.js @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { httpClientMock } from '../../../../../test/mocks'; +import MonitorSecurity from './MonitorSecurity'; +import { FORMIK_INITIAL_VALUES } from '../CreateMonitor/utils/constants'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('MonitorSecurity', () => { + test('renders', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateMonitor/containers/MonitorSecurity/__snapshots__/MonitorSecurity.test.js.snap b/public/pages/CreateMonitor/containers/MonitorSecurity/__snapshots__/MonitorSecurity.test.js.snap new file mode 100644 index 000000000..0ef7200d0 --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorSecurity/__snapshots__/MonitorSecurity.test.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorSecurity renders 1`] = ` + + + +`; diff --git a/public/pages/CreateMonitor/containers/MonitorSecurity/index.js b/public/pages/CreateMonitor/containers/MonitorSecurity/index.js new file mode 100644 index 000000000..0527b2acd --- /dev/null +++ b/public/pages/CreateMonitor/containers/MonitorSecurity/index.js @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import MonitorSecurity from './MonitorSecurity'; + +export default MonitorSecurity; diff --git a/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap b/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap index 040b38d27..a61803cb7 100644 --- a/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap +++ b/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap @@ -81,6 +81,7 @@ exports[`AcknowledgeAlertsModal renders 1`] = ` \\"match_all\\": {} } }", + "roles": Array [], "searchType": "graph", "timeField": "", "timezone": Array [], diff --git a/server/routes/opensearch.js b/server/routes/opensearch.js index 28ade243b..91d0b6a92 100644 --- a/server/routes/opensearch.js +++ b/server/routes/opensearch.js @@ -77,4 +77,16 @@ export default function (services, router) { }, opensearchService.getClusterHealth ); + + router.post( + { + path: '/api/alerting/_roles', + validate: { + body: schema.object({ + role: schema.string(), + }), + }, + }, + opensearchService.getRoles + ); } diff --git a/server/services/OpensearchService.js b/server/services/OpensearchService.js index feafba9cf..9ae7b18c6 100644 --- a/server/services/OpensearchService.js +++ b/server/services/OpensearchService.js @@ -187,4 +187,54 @@ export default class OpensearchService { }); } }; + + getRoles = async (context, req, res) => { + try { + const { role } = req.body; + const { callAsCurrentUser } = this.esDriver.asScoped(req); + + // Try to get all backend roles in the system if the user has sufficient permission + try { + const roles_results = await callAsCurrentUser('transport.request', { + method: 'GET', + path: '_plugins/_security/api/rolesmapping', + }); + let all_backend_roles = new Set(); + + for (let key in roles_results) { + roles_results[key].backend_roles.forEach((item) => all_backend_roles.add(item)); + } + + return res.ok({ + body: { + ok: true, + resp: Array.from(all_backend_roles).filter((item) => !role || item.includes(role)), + }, + }); + } catch (err) { + console.log('Alerting - OpensearchService - getRoles: get rolemappings:', err.message); + } + + // Get users roles + const account_results = await callAsCurrentUser('transport.request', { + method: 'GET', + path: '_plugins/_security/api/account', + }); + + return res.ok({ + body: { + ok: true, + resp: account_results.backend_roles.filter((item) => !role || item.includes(role)), + }, + }); + } catch (err) { + console.error('Alerting - OpensearchService - getRoles:', err); + return res.ok({ + body: { + ok: false, + resp: err.message, + }, + }); + } + }; } diff --git a/server/services/OpensearchService.mock.js b/server/services/OpensearchService.mock.js new file mode 100644 index 000000000..ff300dfec --- /dev/null +++ b/server/services/OpensearchService.mock.js @@ -0,0 +1,8 @@ +export const esDriverMock = jest.fn(); +esDriverMock.call = jest.fn(); +esDriverMock.asScoped = jest.fn().mockReturnValue({ + callAsCurrentUser: esDriverMock.call, +}); + +export const responseMock = jest.fn(); +responseMock.ok = jest.fn().mockImplementation((obj) => obj); diff --git a/server/services/OpensearchService.test.js b/server/services/OpensearchService.test.js new file mode 100644 index 000000000..893510c48 --- /dev/null +++ b/server/services/OpensearchService.test.js @@ -0,0 +1,359 @@ +import { httpClientMock } from '../../test/mocks'; +import OpensearchService from './OpensearchService'; + +import { esDriverMock, responseMock } from './OpensearchService.mock'; + +describe('getIndices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return all local indices', async () => { + const os = new OpensearchService(esDriverMock); + const os_resp = [ + { + health: 'green', + index: 'test-index-a', + status: 'open', + }, + { + health: 'yellow', + index: 'test-index-b', + status: 'open', + }, + { + health: 'yellow', + index: 'test-index-c', + status: 'open', + }, + ]; + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + index: '*', + }, + }; + const resp = { + body: { + ok: true, + resp: os_resp, + }, + }; + expect(await os.getIndices(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('cat.indices', { + format: 'json', + h: 'health,index,status', + index: '*', + }); + }); + + it('should return matching local indices', async () => { + const os = new OpensearchService(esDriverMock); + const os_resp = [ + { + health: 'green', + index: 'test-index-a', + status: 'open', + }, + { + health: 'yellow', + index: 'test-index-b', + status: 'open', + }, + ]; + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + index: 'test*', + }, + }; + const resp = { + body: { + ok: true, + resp: os_resp, + }, + }; + expect(await os.getIndices(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('cat.indices', { + format: 'json', + h: 'health,index,status', + index: 'test*', + }); + }); +}); + +describe('getAliases', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return all local aliases', async () => { + const os = new OpensearchService(esDriverMock); + const os_resp = [ + { + alias: 'test_index', + index: 'test_index_2', + }, + { + alias: 'test_index', + index: 'test_index_1', + }, + ]; + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + alias: '*', + }, + }; + const resp = { + body: { + ok: true, + resp: os_resp, + }, + }; + expect(await os.getAliases(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('cat.aliases', { + alias: '*', + format: 'json', + h: 'alias,index', + }); + }); + + it('should return matching local aliases', async () => { + const os = new OpensearchService(esDriverMock); + const os_resp = [ + { + alias: 'test_index', + index: 'test_index_2', + }, + { + alias: 'test_index', + index: 'test_index_1', + }, + ]; + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + alias: 'test*', + }, + }; + const resp = { + body: { + ok: true, + resp: os_resp, + }, + }; + expect(await os.getAliases(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('cat.aliases', { + alias: 'test*', + format: 'json', + h: 'alias,index', + }); + }); +}); + +describe('getMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return local mappings', async () => { + const os = new OpensearchService(esDriverMock); + const os_resp = { + test_index_2: { + mappings: { properties: { fieldA: { type: 'text' }, fieldB: { type: 'date' } } }, + }, + test_index_1: { + mappings: { properties: { fieldA: { type: 'text' }, fieldB: { type: 'date' } } }, + }, + }; + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + index: ['test*'], + }, + }; + const resp = { + body: { + ok: true, + resp: os_resp, + }, + }; + expect(await os.getMappings(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('indices.getMapping', { + index: ['test*'], + }); + }); +}); + +describe('getRoles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const os_resp = { + manage_snapshots: { + hosts: [], + users: [], + reserved: false, + hidden: false, + backend_roles: ['snapshotrestore'], + and_backend_roles: [], + }, + logstash: { + hosts: [], + users: [], + reserved: false, + hidden: false, + backend_roles: ['logstash'], + and_backend_roles: [], + }, + own_index: { + hosts: [], + users: ['*'], + reserved: false, + hidden: false, + backend_roles: [], + and_backend_roles: [], + description: 'Allow full access to an index named like the username', + }, + alerting_full_access: { + hosts: [], + users: [], + reserved: false, + hidden: false, + backend_roles: ['test_bk_role_1', 'test_bk_role_2'], + and_backend_roles: [], + }, + all_access: { + hosts: [], + users: [], + reserved: false, + hidden: false, + backend_roles: ['admin'], + and_backend_roles: [], + description: 'Maps admin to all_access', + }, + readall: { + hosts: [], + users: [], + reserved: false, + hidden: false, + backend_roles: ['readall', 'test_bk_role_2'], + and_backend_roles: [], + }, + }; + + const user_role_os_resp = { + user_name: 'test_user_1', + is_reserved: false, + is_hidden: false, + is_internal_user: true, + user_requested_tenant: null, + backend_roles: ['test_bk_role_2', 'test_bk_role_1'], + custom_attribute_names: [], + tenants: { test_user_1: true }, + roles: ['manage_snapshots', 'own_index', 'alerting_full_access', 'readall'], + }; + + it('should return all cluster roles', async () => { + const os = new OpensearchService(esDriverMock); + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + role: [], + }, + }; + const resp = { + body: { + ok: true, + resp: [ + 'snapshotrestore', + 'logstash', + 'test_bk_role_1', + 'test_bk_role_2', + 'admin', + 'readall', + ], + }, + }; + expect(await os.getRoles(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '_plugins/_security/api/rolesmapping', + }); + }); + + it('should return specific cluster role', async () => { + const os = new OpensearchService(esDriverMock); + esDriverMock.call.mockReturnValue(os_resp); + const requestMock = { + body: { + role: ['test_bk_role_1'], + }, + }; + const resp = { + body: { + ok: true, + resp: ['test_bk_role_1'], + }, + }; + expect(await os.getRoles(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '_plugins/_security/api/rolesmapping', + }); + }); + + it('should return all user roles', async () => { + const os = new OpensearchService(esDriverMock); + esDriverMock.call.mockRejectedValueOnce(new Error('Authorization Exception')); + esDriverMock.call.mockReturnValueOnce(user_role_os_resp); + const requestMock = { + body: { + role: [], + }, + }; + const resp = { + body: { + ok: true, + resp: ['test_bk_role_2', 'test_bk_role_1'], + }, + }; + expect(await os.getRoles(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '_plugins/_security/api/rolesmapping', + }); + expect(esDriverMock.call).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '_plugins/_security/api/account', + }); + }); + + it('should return specific user role', async () => { + const os = new OpensearchService(esDriverMock); + esDriverMock.call.mockRejectedValueOnce(new Error('Authorization Exception')); + esDriverMock.call.mockReturnValueOnce(user_role_os_resp); + const requestMock = { + body: { + role: ['test_bk_role_1'], + }, + }; + const resp = { + body: { + ok: true, + resp: ['test_bk_role_1'], + }, + }; + expect(await os.getRoles(undefined, requestMock, responseMock)).toEqual(resp); + expect(esDriverMock.call).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '_plugins/_security/api/rolesmapping', + }); + expect(esDriverMock.call).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '_plugins/_security/api/account', + }); + }); +});