diff --git a/common/constants/data_connections.ts b/common/constants/data_connections.ts index 354d797de9..2c06e935ee 100644 --- a/common/constants/data_connections.ts +++ b/common/constants/data_connections.ts @@ -8,3 +8,6 @@ export const OPENSEARCH_DOCUMENTATION_URL = export const OPENSEARCH_ACC_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/data-acceleration/index'; + +export const QUERY_RESTRICTED = 'query-restricted'; +export const QUERY_ALL = 'query-all'; diff --git a/common/types/data_connections.ts b/common/types/data_connections.ts new file mode 100644 index 0000000000..9fd0527d96 --- /dev/null +++ b/common/types/data_connections.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface PermissionsConfigurationProps { + roles: Array<{ label: string }>; + selectedRoles: Array<{ label: string }>; + setSelectedRoles: React.Dispatch>>; +} diff --git a/public/components/data_connections/components/__tests__/__snapshots__/data_connection.test.tsx.snap b/public/components/data_connections/components/__tests__/__snapshots__/data_connection.test.tsx.snap index 6b0a6e92c1..adf1595cf9 100644 --- a/public/components/data_connections/components/__tests__/__snapshots__/data_connection.test.tsx.snap +++ b/public/components/data_connections/components/__tests__/__snapshots__/data_connection.test.tsx.snap @@ -259,46 +259,59 @@ exports[`Data Connection Page test Renders data connection page with data 1`] = -
- - + - + + + Connection configuration + + +
+
({ + coreRefs: { + chrome: { + setBreadcrumbs: jest.fn(), + }, + http: { + get: jest.fn().mockResolvedValue(describeDataConnection), + }, + }, +})); + describe('Data Connection Page test', () => { configure({ adapter: new Adapter() }); it('Renders data connection page with data', async () => { - const http = { - get: jest.fn().mockResolvedValue(describeDataConnection), - }; const pplService = { fetch: jest.fn(), }; - const mockChrome = { - setBreadcrumbs: jest.fn(), - }; - const wrapper = mount( - - ); + const wrapper = mount(); const container = document.createElement('div'); await act(() => { - ReactDOM.render( - , - container - ); + ReactDOM.render(, container); }); expect(container).toMatchSnapshot(); }); diff --git a/public/components/data_connections/components/__tests__/manage_data_connections_table.test.tsx b/public/components/data_connections/components/__tests__/manage_data_connections_table.test.tsx index 380fcf3924..fe7369426a 100644 --- a/public/components/data_connections/components/__tests__/manage_data_connections_table.test.tsx +++ b/public/components/data_connections/components/__tests__/manage_data_connections_table.test.tsx @@ -10,6 +10,18 @@ import React from 'react'; import { ManageDataConnectionsTable } from '../manage_data_connections_table'; import { showDataConnectionsData } from './testing_constants'; import ReactDOM from 'react-dom'; +import { coreRefs } from '../../../../../public/framework/core_refs'; + +jest.mock('../../../../../public/framework/core_refs', () => ({ + coreRefs: { + chrome: { + setBreadcrumbs: jest.fn(), + }, + http: { + get: jest.fn().mockResolvedValue(showDataConnectionsData), + }, + }, +})); describe('Manage Data Connections Table test', () => { configure({ adapter: new Adapter() }); @@ -19,7 +31,7 @@ describe('Manage Data Connections Table test', () => { get: jest.fn().mockResolvedValue(showDataConnectionsData), }; const pplService = { - fetch: jest.fn(), + fetch: jest.fn().mockResolvedValue(showDataConnectionsData), }; const mockChrome = { setBreadcrumbs: jest.fn(), diff --git a/public/components/data_connections/components/__tests__/testing_constants.ts b/public/components/data_connections/components/__tests__/testing_constants.ts index 995e149fe8..65258fb27b 100644 --- a/public/components/data_connections/components/__tests__/testing_constants.ts +++ b/public/components/data_connections/components/__tests__/testing_constants.ts @@ -3,61 +3,43 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const showDataConnectionsData = [ - { - name: 'my_spark3', - connector: 'SPARK', - allowedRoles: [], - properties: { - 'spark.connector': 'emr', - 'spark.datasource.flint.host': '0.0.0.0', - 'spark.datasource.flint.integration': - 'https://aws.oss.sonatype.org/content/repositories/snapshots/org/opensearch/opensearch-spark-standalone_2.12/0.1.0-SNAPSHOT/opensearch-spark-standalone_2.12-0.1.0-20230731.182705-3.jar', - 'spark.datasource.flint.port': '9200', - 'spark.datasource.flint.scheme': 'http', - 'emr.cluster': 'j-3UNQLT1MPBGLG', +export const showDataConnectionsData = { + schema: [ + { + name: 'DATASOURCE_NAME', + type: 'string', }, - }, - { - name: 'my_spark4', - connector: 'SPARK', - allowedRoles: [], - properties: { - 'spark.connector': 'emr', - 'spark.datasource.flint.host': '15.248.1.68', - 'spark.datasource.flint.integration': - 'https://aws.oss.sonatype.org/content/repositories/snapshots/org/opensearch/opensearch-spark-standalone_2.12/0.1.0-SNAPSHOT/opensearch-spark-standalone_2.12-0.1.0-20230731.182705-3.jar', - 'spark.datasource.flint.port': '9200', - 'spark.datasource.flint.scheme': 'http', - 'emr.cluster': 'j-3UNQLT1MPBGLG', + { + name: 'CONNECTOR_TYPE', + type: 'string', }, - }, - { - name: 'my_spark', - connector: 'SPARK', - allowedRoles: [], - properties: { - 'spark.connector': 'emr', - 'spark.datasource.flint.host': '0.0.0.0', - 'spark.datasource.flint.port': '9200', - 'spark.datasource.flint.scheme': 'http', - 'spark.datasource.flint.region': 'xxx', - 'emr.cluster': 'xxx', + ], + datarows: [ + ['my_spark_actual', 'SPARK'], + ['@opensearch', 'OPENSEARCH'], + ['my_spark', 'SPARK'], + ], + total: 3, + size: 3, + jsonData: [ + { + DATASOURCE_NAME: 'my_spark3', + CONNECTOR_TYPE: 'SPARK', }, - }, - { - name: 'my_spark2', - connector: 'SPARK', - allowedRoles: [], - properties: { - 'spark.connector': 'emr', - 'spark.datasource.flint.host': '0.0.0.0', - 'spark.datasource.flint.port': '9200', - 'spark.datasource.flint.scheme': 'http', - 'emr.cluster': 'j-3UNQLT1MPBGLG', + { + DATASOURCE_NAME: 'my_spark4', + CONNECTOR_TYPE: 'SPARK', }, - }, -]; + { + DATASOURCE_NAME: 'my_spark', + CONNECTOR_TYPE: 'SPARK', + }, + { + DATASOURCE_NAME: 'my_spark2', + CONNECTOR_TYPE: 'SPARK', + }, + ], +}; export const describeDataConnection = { name: 'my_spark3', diff --git a/public/components/data_connections/components/access_control_callout.tsx b/public/components/data_connections/components/access_control_callout.tsx new file mode 100644 index 0000000000..f675673ffc --- /dev/null +++ b/public/components/data_connections/components/access_control_callout.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCallOut } from '@elastic/eui'; +import React from 'react'; + +export const AccessControlCallout = () => { + return ( + + Access to data can be managed in other systems outside of OpenSearch. Check with your + administrator for additional configurations. + + ); +}; diff --git a/public/components/data_connections/components/access_control_tab.tsx b/public/components/data_connections/components/access_control_tab.tsx new file mode 100644 index 0000000000..15c8491b90 --- /dev/null +++ b/public/components/data_connections/components/access_control_tab.tsx @@ -0,0 +1,153 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiHorizontalRule, + EuiBottomBar, + EuiButtonEmpty, +} from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { EuiPanel } from '@elastic/eui'; +import { QUERY_ALL, QUERY_RESTRICT } from '../../../../common/constants/data_connections'; +import { AccessControlCallout } from './access_control_callout'; +import { coreRefs } from '../../../../public/framework/core_refs'; +import { QueryPermissionsConfiguration } from './query_permissions'; +import { DATACONNECTIONS_BASE } from '../../../../common/constants/shared'; + +interface AccessControlTabProps { + dataConnection: string; + connector: string; + properties: unknown; +} + +export const AccessControlTab = (props: AccessControlTabProps) => { + const [mode, setMode] = useState<'view' | 'edit'>('view'); + const [roles, setRoles] = useState>([]); + const [selectedQueryPermissionRoles, setSelectedQueryPermissionRoles] = useState< + Array<{ label: string }> + >([]); + const { http } = coreRefs; + + useEffect(() => { + http!.get('/api/v1/configuration/roles').then((data) => + setRoles( + Object.keys(data.data).map((key) => { + return { label: key }; + }) + ) + ); + }, []); + + const AccessControlDetails = () => { + return ( + + + + + Query access + + {[].length ? `Restricted` : '-'} + + + + + + ); + }; + + const EditAccessControlDetails = () => { + return ( + + + + ); + }; + + const saveChanges = () => { + http!.put(`${DATACONNECTIONS_BASE}`, { + body: JSON.stringify({ + name: props.dataConnection, + allowedRoles: selectedQueryPermissionRoles.map((role) => role.label), + connector: props.connector, + properties: props.properties, + }), + }); + setMode('view'); + }; + + const AccessControlHeader = () => { + return ( + + + +

Access Control

+ Control which OpenSearch users have access to this data source. +
+
+ + + setMode(mode === 'view' ? 'edit' : 'view')} + fill={mode === 'view' ? true : false} + > + {mode === 'view' ? 'Edit' : 'Cancel'} + + +
+ ); + }; + + const SaveOrCancel = () => { + return ( + + + + { + setMode('view'); + }} + color="ghost" + size="s" + iconType="cross" + > + Discard change(s) + + + + + Save + + + + + ); + }; + + return ( + <> + + + + + + + {mode === 'view' ? : } + + + {mode === 'edit' && } + + + ); +}; diff --git a/public/components/data_connections/components/data_connection.tsx b/public/components/data_connections/components/data_connection.tsx index 1913e0eb05..0df41e30f9 100644 --- a/public/components/data_connections/components/data_connection.tsx +++ b/public/components/data_connections/components/data_connection.tsx @@ -18,9 +18,10 @@ import { EuiIcon, EuiCard, EuiTab, - EuiTabs, + EuiTabbedContent, } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; +import { AccessControlTab } from './access_control_tab'; import { NoAccess } from './no_access'; import { DATACONNECTIONS_BASE } from '../../../../common/constants/shared'; import { coreRefs } from '../../../../public/framework/core_refs'; @@ -29,6 +30,8 @@ interface DatasourceDetails { allowedRoles: string[]; name: string; cluster: string; + connector: string; + properties: unknown; } export const DataConnection = (props: any) => { @@ -37,18 +40,32 @@ export const DataConnection = (props: any) => { allowedRoles: [], name: '', cluster: '', + connector: '', + properties: {}, }); const [hasAccess, setHasAccess] = useState(true); - const { http } = coreRefs; + const { http, chrome } = coreRefs; useEffect(() => { - http + chrome!.setBreadcrumbs([ + { + text: 'Data Connections', + href: '#/', + }, + { + text: `${dataSource}`, + href: `#/manage/${dataSource}`, + }, + ]); + http! .get(`${DATACONNECTIONS_BASE}/${dataSource}`) .then((data) => setDatasourceDetails({ allowedRoles: data.allowedRoles, name: data.name, cluster: data.properties['emr.cluster'], + connector: data.connector, + properties: data.properties, }) ) .catch((err) => { @@ -56,45 +73,35 @@ export const DataConnection = (props: any) => { setHasAccess(false); } }); - }, []); + }, [chrome, http]); const tabs = [ { id: 'data', name: 'Data', disabled: false, + content: <>, }, { id: 'access_control', name: 'Access control', disabled: false, + content: ( + + ), }, { id: 'connection_configuration', name: 'Connection configuration', disabled: false, + content: <>, }, ]; - const [selectedTabId, setSelectedTabId] = useState('data'); - - const onSelectedTabChanged = (id) => { - setSelectedTabId(id); - }; - - const renderTabs = () => { - return tabs.map((tab, index) => ( - onSelectedTabChanged(tab.id)} - isSelected={tab.id === selectedTabId} - disabled={tab.disabled} - key={index} - > - {tab.name} - - )); - }; - const renderOverview = () => { return ( @@ -184,7 +191,7 @@ export const DataConnection = (props: any) => { - {renderTabs()} + diff --git a/public/components/data_connections/components/no_access.tsx b/public/components/data_connections/components/no_access.tsx index 0bb0bae2fa..5c07b28931 100644 --- a/public/components/data_connections/components/no_access.tsx +++ b/public/components/data_connections/components/no_access.tsx @@ -3,36 +3,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiButton, EuiEmptyPrompt, EuiPage, EuiPanel, EuiText } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiPage, EuiText } from '@elastic/eui'; import _ from 'lodash'; import React from 'react'; -import { OPENSEARCH_DOCUMENTATION_URL } from '../../../../common/constants/data_connections'; export const NoAccess = () => { return ( - - Data connections not set up} - body={ - - { - 'You do not have the permissions to view and edit data connections. Please reach out to your administrator for access.' - } - - } - actions={ - window.open(OPENSEARCH_DOCUMENTATION_URL, '_blank')} - > - Learn more - - } - /> - + {'No permissions to access'}} + body={ + + { + 'Missing permissions to view connection details. Contact your administrator for permissions.' + } + + } + actions={ + (window.location.hash = '')}> + Return to data connections + + } + /> ); }; diff --git a/public/components/data_connections/components/query_permissions.tsx b/public/components/data_connections/components/query_permissions.tsx new file mode 100644 index 0000000000..d983015fe4 --- /dev/null +++ b/public/components/data_connections/components/query_permissions.tsx @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiRadioGroup, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { + OPENSEARCH_DOCUMENTATION_URL, + QUERY_ALL, + QUERY_RESTRICTED, +} from '../../../../common/constants/data_connections'; +import { PermissionsConfigurationProps } from '../../../../common/types/data_connections'; + +export const QueryPermissionsConfiguration = (props: PermissionsConfigurationProps) => { + const { roles, selectedRoles, setSelectedRoles } = props; + + const [selectedRadio, setSelectedRadio] = useState( + selectedRoles.length ? QUERY_RESTRICTED : QUERY_ALL + ); + const radios = [ + { + id: QUERY_RESTRICTED, + label: 'Restricted - accessible by users with specific OpenSearch roles', + }, + { + id: QUERY_ALL, + label: 'Everyone - accessible by all users on this cluster', + }, + ]; + + const ConfigureRoles = () => { + return ( +
+ + OpenSearch Roles + + Select one or more OpenSearch roles that can query this data connection. + + +
+ ); + }; + + return ( + + + + Query Permissions + + Control which OpenSearch roles have query permissions on this data source.{' '} + + Learn more + + + + + setSelectedRadio(id)} + name="query-radio-group" + legend={{ + children: Access level, + }} + /> + {selectedRadio === QUERY_RESTRICTED && } + + + + ); +}; diff --git a/server/adaptors/ppl_plugin.ts b/server/adaptors/ppl_plugin.ts index 71dbcb5696..ddc2a2ccf9 100644 --- a/server/adaptors/ppl_plugin.ts +++ b/server/adaptors/ppl_plugin.ts @@ -55,6 +55,14 @@ export const PPLPlugin = function (Client, config, components) { method: 'GET', }); + ppl.modifyDataConnection = ca({ + url: { + fmt: `${OPENSEARCH_DATACONNECTIONS_API.DATACONNECTION}`, + }, + needBody: true, + method: 'PUT', + }); + ppl.getDataConnections = ca({ url: { fmt: `${OPENSEARCH_DATACONNECTIONS_API.DATACONNECTION}`, diff --git a/server/routes/data_connections/data_connections_router.ts b/server/routes/data_connections/data_connections_router.ts index ff276db80e..b0f2b2bade 100644 --- a/server/routes/data_connections/data_connections_router.ts +++ b/server/routes/data_connections/data_connections_router.ts @@ -37,6 +37,43 @@ export function registerDataConnectionsRoute(router: IRouter) { } ); + router.put( + { + path: `${DATACONNECTIONS_BASE}`, + validate: { + body: schema.object({ + name: schema.string(), + connector: schema.string(), + allowedRoles: schema.arrayOf(schema.string()), + properties: schema.any(), + }), + }, + }, + async (context, request, response): Promise => { + try { + const dataConnectionsresponse = await context.observability_plugin.observabilityClient + .asScoped(request) + .callAsCurrentUser('ppl.modifyDataConnection', { + body: { + name: request.body.name, + connector: request.body.connector, + allowedRoles: request.body.allowedRoles, + properties: request.body.properties, + }, + }); + return response.ok({ + body: dataConnectionsresponse, + }); + } catch (error: any) { + console.error('Issue in modifying data connection:', error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.get( { path: `${DATACONNECTIONS_BASE}`,