From b3e444fa6f3af1410c10a937a44cb1b2fe0127cb Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 3 Jun 2024 11:15:51 -0400 Subject: [PATCH] Always show security screen and shows error page when trying to access forbidden data-source (#1964) Signed-off-by: Darshit Chanpura --- .../configuration/access-error-component.tsx | 35 ++++ public/apps/configuration/app-router.tsx | 43 ++--- .../audit-logging-edit-settings.tsx | 2 +- .../panels/audit-logging/audit-logging.tsx | 43 ++++- .../__snapshots__/audit-logging.test.tsx.snap | 54 +++++- .../audit-logging/test/audit-logging.test.tsx | 40 ++++- .../panels/auth-view/auth-view.tsx | 54 ++++-- .../__snapshots__/auth-view.test.tsx.snap | 31 ++++ .../panels/auth-view/test/auth-view.test.tsx | 56 ++++-- .../internal-user-edit/internal-user-edit.tsx | 2 +- .../permission-list/permission-list.tsx | 125 +++++++------ .../permission-list.test.tsx.snap | 41 +++++ .../test/permission-list.test.tsx | 23 +++ .../panels/role-edit/role-edit.tsx | 6 +- .../apps/configuration/panels/role-list.tsx | 120 +++++++------ .../role-mapping/role-edit-mapped-user.tsx | 4 +- .../panels/role-view/role-view.tsx | 2 +- .../panels/service-account-list.tsx | 121 +++++++------ .../panels/tenant-list/configure_tab1.tsx | 48 +++-- .../panels/tenant-list/manage_tab.tsx | 22 ++- .../panels/tenant-list/tenant-list.tsx | 5 +- .../__snapshots__/tenant-list.test.tsx.snap | 18 +- .../tenant-list/test/tenant-list.test.tsx | 146 +++++++++++++++- .../__snapshots__/role-list.test.tsx.snap | 164 ++++++++++++++++++ .../service-account-list.test.tsx.snap | 37 ++++ .../__snapshots__/user-list.test.tsx.snap | 39 +++++ .../panels/test/role-list.test.tsx | 43 ++++- .../panels/test/service-account-list.test.tsx | 56 +++++- .../panels/test/user-list.test.tsx | 46 ++++- .../apps/configuration/panels/user-list.tsx | 138 ++++++++------- .../access-error-component.test.tsx.snap | 15 ++ .../test/access-error-component.test.tsx | 48 +++++ public/apps/configuration/top-nav-menu.tsx | 15 +- public/plugin.ts | 3 +- public/utils/datasource-utils.ts | 7 +- public/utils/test/datasource-utils.test.ts | 9 + 36 files changed, 1321 insertions(+), 340 deletions(-) create mode 100644 public/apps/configuration/access-error-component.tsx create mode 100644 public/apps/configuration/panels/auth-view/test/__snapshots__/auth-view.test.tsx.snap create mode 100644 public/apps/configuration/panels/permission-list/test/__snapshots__/permission-list.test.tsx.snap create mode 100644 public/apps/configuration/panels/test/__snapshots__/service-account-list.test.tsx.snap create mode 100644 public/apps/configuration/panels/test/__snapshots__/user-list.test.tsx.snap create mode 100644 public/apps/configuration/test/__snapshots__/access-error-component.test.tsx.snap create mode 100644 public/apps/configuration/test/access-error-component.test.tsx diff --git a/public/apps/configuration/access-error-component.tsx b/public/apps/configuration/access-error-component.tsx new file mode 100644 index 000000000..aa9036c41 --- /dev/null +++ b/public/apps/configuration/access-error-component.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { EuiLoadingContent, EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +interface AccessErrorComponentProps { + loading?: boolean; + dataSourceLabel?: string; + message?: string; +} + +export const AccessErrorComponent: React.FC = (props) => { + const { + loading = false, + dataSourceLabel, + message = 'You do not have permissions to view this data', + } = props; + + const displayMessage = message + (dataSourceLabel ? ` for ${props.dataSourceLabel}.` : '.'); + + return loading ? : {displayMessage}; +}; diff --git a/public/apps/configuration/app-router.tsx b/public/apps/configuration/app-router.tsx index 782d833a7..53e0e5536 100644 --- a/public/apps/configuration/app-router.tsx +++ b/public/apps/configuration/app-router.tsx @@ -163,30 +163,6 @@ export function AppRouter(props: AppDependencies) { const [dataSource, setDataSource] = useState(dataSourceFromUrl); - function getTenancyRoutes() { - if (multitenancyEnabled) { - return ( - <> - { - setGlobalBreadcrumbs(ResourceType.tenants); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.tenants); - return ; - }} - /> - - ); - } - return null; - } - return ( @@ -300,7 +276,24 @@ export function AppRouter(props: AppDependencies) { return ; }} /> - {getTenancyRoutes()} + {multitenancyEnabled && ( + { + setGlobalBreadcrumbs(ResourceType.tenants); + return ; + }} + /> + )} + {multitenancyEnabled && ( + { + setGlobalBreadcrumbs(ResourceType.tenants); + return ; + }} + /> + )} diff --git a/public/apps/configuration/panels/audit-logging/audit-logging-edit-settings.tsx b/public/apps/configuration/panels/audit-logging/audit-logging-edit-settings.tsx index 8e89d62a7..05f705e24 100644 --- a/public/apps/configuration/panels/audit-logging/audit-logging-edit-settings.tsx +++ b/public/apps/configuration/panels/audit-logging/audit-logging-edit-settings.tsx @@ -76,7 +76,7 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { }; fetchConfig(); - }, [props.coreStart.http, dataSource.id]); + }, [props.coreStart.http, dataSource]); const renderSaveAndCancel = () => { return ( diff --git a/public/apps/configuration/panels/audit-logging/audit-logging.tsx b/public/apps/configuration/panels/audit-logging/audit-logging.tsx index 7b3bd5429..338a7d7a5 100644 --- a/public/apps/configuration/panels/audit-logging/audit-logging.tsx +++ b/public/apps/configuration/panels/audit-logging/audit-logging.tsx @@ -22,6 +22,8 @@ import { EuiForm, EuiFormRow, EuiHorizontalRule, + EuiLoadingContent, + EuiPageHeader, EuiPanel, EuiSpacer, EuiSwitch, @@ -30,6 +32,7 @@ import { } from '@elastic/eui'; import React, { useContext } from 'react'; import { FormattedMessage } from '@osd/i18n/react'; +import { DataSourceOption } from 'src/plugins/data_source_management/public'; import { AppDependencies } from '../../../types'; import { ResourceType } from '../../../../../common'; import { getAuditLogging, updateAuditLogging } from '../../utils/audit-logging-utils'; @@ -45,6 +48,7 @@ import { ViewSettingGroup } from './view-setting-group'; import { DocLinks } from '../../constants'; import { DataSourceContext } from '../../app-router'; import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; +import { AccessErrorComponent } from '../../access-error-component'; interface AuditLoggingProps extends AppDependencies { fromType: string; @@ -53,10 +57,6 @@ interface AuditLoggingProps extends AppDependencies { function renderStatusPanel(onSwitchChange: () => void, auditLoggingEnabled: boolean) { return ( - -

Audit logging

-
- Storage location} className="described-form-group"> @@ -93,6 +93,16 @@ function renderStatusPanel(onSwitchChange: () => void, auditLoggingEnabled: bool ); } +function renderAccessErrorPanel(loading: boolean, dataSource: DataSourceOption) { + return ( + + ); +} + export function renderGeneralSettings(config: AuditLoggingSettings) { return ( <> @@ -137,6 +147,8 @@ export function renderComplianceSettings(config: AuditLoggingSettings) { export function AuditLogging(props: AuditLoggingProps) { const [configuration, setConfiguration] = React.useState({}); const { dataSource, setDataSource } = useContext(DataSourceContext)!; + const [loading, setLoading] = React.useState(false); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); const onSwitchChange = async () => { try { @@ -154,29 +166,38 @@ export function AuditLogging(props: AuditLoggingProps) { React.useEffect(() => { const fetchData = async () => { try { + setLoading(true); const auditLogging = await getAuditLogging(props.coreStart.http, dataSource.id); setConfiguration(auditLogging); + setAccessErrorFlag(false); } catch (e) { // TODO: switch to better error handling. console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } + } finally { + setLoading(false); } }; fetchData(); - }, [props.coreStart.http, props.fromType, dataSource.id]); + }, [props.coreStart.http, props.fromType, dataSource]); const statusPanel = renderStatusPanel(onSwitchChange, configuration.enabled || false); let content; - if (!configuration.enabled) { + if (accessErrorFlag) { + content = renderAccessErrorPanel(loading, dataSource); + } else if (!configuration.enabled) { content = statusPanel; } else { content = ( <> {statusPanel} - @@ -237,7 +258,13 @@ export function AuditLogging(props: AuditLoggingProps) { setDataSource={setDataSource} selectedDataSource={dataSource} /> - {content} + + +

Audit Logging

+
+
+ + {loading ? : content} ); } diff --git a/public/apps/configuration/panels/audit-logging/test/__snapshots__/audit-logging.test.tsx.snap b/public/apps/configuration/panels/audit-logging/test/__snapshots__/audit-logging.test.tsx.snap index 9fdee791a..e44012a64 100644 --- a/public/apps/configuration/panels/audit-logging/test/__snapshots__/audit-logging.test.tsx.snap +++ b/public/apps/configuration/panels/audit-logging/test/__snapshots__/audit-logging.test.tsx.snap @@ -256,15 +256,17 @@ exports[`Audit logs render when AuditLoggingSettings.enabled is true 1`] = ` } setDataSource={[MockFunction]} /> - - -

- Audit logging -

+ + +

+ Audit Logging +

- +
+ + `; + +exports[`Audit logs should load access error component 1`] = ` +
+ + + +

+ Audit Logging +

+
+
+ + +
+`; diff --git a/public/apps/configuration/panels/audit-logging/test/audit-logging.test.tsx b/public/apps/configuration/panels/audit-logging/test/audit-logging.test.tsx index 3f5c6e8da..7710b9eff 100644 --- a/public/apps/configuration/panels/audit-logging/test/audit-logging.test.tsx +++ b/public/apps/configuration/panels/audit-logging/test/audit-logging.test.tsx @@ -40,7 +40,11 @@ describe('Audit logs', () => { }; beforeEach(() => { - jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, setState]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [false, jest.fn()]); jest.spyOn(React, 'useEffect').mockImplementationOnce((f) => f()); }); @@ -140,7 +144,11 @@ describe('Audit logs', () => { it('render when AuditLoggingSettings.enabled is true', () => { const auditLoggingSettings = { enabled: true }; - jest.spyOn(React, 'useState').mockImplementation(() => [auditLoggingSettings, setState]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [auditLoggingSettings, setState]) + .mockImplementationOnce(() => [false, jest.fn()]); const component = shallow( ); @@ -149,7 +157,11 @@ describe('Audit logs', () => { it('Click Configure button of general setting section', () => { const auditLoggingSettings = { enabled: true }; - jest.spyOn(React, 'useState').mockImplementation(() => [auditLoggingSettings, setState]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [auditLoggingSettings, setState]) + .mockImplementationOnce(() => [false, jest.fn()]); const component = shallow( ); @@ -161,7 +173,11 @@ describe('Audit logs', () => { it('Click Configure button of Compliance settings section', () => { const auditLoggingSettings = { enabled: true }; - jest.spyOn(React, 'useState').mockImplementation(() => [auditLoggingSettings, setState]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [auditLoggingSettings, setState]) + .mockImplementationOnce(() => [false, jest.fn()]); const component = shallow( ); @@ -170,4 +186,20 @@ describe('Audit logs', () => { buildHashUrl(ResourceType.auditLogging) + SUB_URL_FOR_COMPLIANCE_SETTINGS_EDIT ); }); + + it('should load access error component', () => { + const auditLoggingSettings = { enabled: true }; + mockAuditLoggingUtils.getAuditLogging = jest + .fn() + .mockRejectedValue({ response: { status: 403 } }); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [auditLoggingSettings, setState]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [true, jest.fn()]); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); }); diff --git a/public/apps/configuration/panels/auth-view/auth-view.tsx b/public/apps/configuration/panels/auth-view/auth-view.tsx index f425a357c..8022ca260 100644 --- a/public/apps/configuration/panels/auth-view/auth-view.tsx +++ b/public/apps/configuration/panels/auth-view/auth-view.tsx @@ -13,8 +13,8 @@ * permissions and limitations under the License. */ -import React, { useContext, useState } from 'react'; -import { EuiPageHeader, EuiSpacer, EuiTitle } from '@elastic/eui'; +import React, { useContext } from 'react'; +import { EuiLoadingContent, EuiPageHeader, EuiSpacer, EuiTitle } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { AuthenticationSequencePanel } from './authentication-sequence-panel'; import { AuthorizationPanel } from './authorization-panel'; @@ -25,12 +25,15 @@ import { getSecurityConfig } from '../../utils/auth-view-utils'; import { InstructionView } from './instruction-view'; import { DataSourceContext } from '../../app-router'; import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; +import { AccessErrorComponent } from '../../access-error-component'; export function AuthView(props: AppDependencies) { const [authentication, setAuthentication] = React.useState([]); const [authorization, setAuthorization] = React.useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = React.useState(false); const { dataSource, setDataSource } = useContext(DataSourceContext)!; + const [errorFlag, setErrorFlag] = React.useState(false); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); React.useEffect(() => { const fetchData = async () => { @@ -40,17 +43,24 @@ export function AuthView(props: AppDependencies) { setAuthentication(config.authc); setAuthorization(config.authz); + setErrorFlag(false); + setAccessErrorFlag(false); } catch (e) { console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } + setErrorFlag(true); } finally { setLoading(false); } }; fetchData(); - }, [props.coreStart.http, dataSource.id]); + }, [props.coreStart.http, dataSource]); - if (isEmpty(authentication)) { + if (isEmpty(authentication) && !loading) { return ( <> - + {accessErrorFlag ? ( + +

Authentication and authorization

+
+ ) : null} + {accessErrorFlag ? ( + + ) : ( + + )} ); } @@ -76,18 +98,26 @@ export function AuthView(props: AppDependencies) {

Authentication and authorization

- {props.config.ui.backend_configurable && ( + {!loading && !errorFlag && props.config.ui.backend_configurable && ( )} - {/* @ts-ignore */} - - - {/* @ts-ignore */} - + {loading ? ( + + ) : accessErrorFlag ? ( + + ) : ( + <> + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + )} ); } diff --git a/public/apps/configuration/panels/auth-view/test/__snapshots__/auth-view.test.tsx.snap b/public/apps/configuration/panels/auth-view/test/__snapshots__/auth-view.test.tsx.snap new file mode 100644 index 000000000..aff22a5fb --- /dev/null +++ b/public/apps/configuration/panels/auth-view/test/__snapshots__/auth-view.test.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Auth view should load access error component 1`] = ` + + + +

+ Authentication and authorization +

+
+ +
+`; diff --git a/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx b/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx index 8a97113ec..5643f86fa 100644 --- a/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx +++ b/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx @@ -29,31 +29,37 @@ describe('Auth view', () => { const mockCoreStart = { http: 1, }; + const config = { + authc: { + basic_internal_auth_domain: { + authentication_backend: { + type: 'intern', + config: {}, + }, + }, + }, + authz: { + ldap: { + http_enabled: true, + }, + }, + }; const setState = jest.fn(); beforeEach(() => { - jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, setState]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); jest.spyOn(React, 'useEffect').mockImplementationOnce((f) => f()); }); it('valid data', (done) => { - const config = { - authc: { - basic_internal_auth_domain: { - authentication_backend: { - type: 'intern', - config: {}, - }, - }, - }, - authz: { - ldap: { - http_enabled: true, - }, - }, - }; - mockAuthViewUtils.getSecurityConfig = jest.fn().mockReturnValue(config); shallow(); @@ -85,4 +91,20 @@ describe('Auth view', () => { done(); }); }); + + it('should load access error component', async () => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [true, jest.fn()]); + mockAuthViewUtils.getSecurityConfig = jest + .fn() + .mockRejectedValue({ response: { status: 403 } }); + const component = shallow(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/public/apps/configuration/panels/internal-user-edit/internal-user-edit.tsx b/public/apps/configuration/panels/internal-user-edit/internal-user-edit.tsx index 70ba39be8..986ff8e05 100644 --- a/public/apps/configuration/panels/internal-user-edit/internal-user-edit.tsx +++ b/public/apps/configuration/panels/internal-user-edit/internal-user-edit.tsx @@ -98,7 +98,7 @@ export function InternalUserEdit(props: InternalUserEditDeps) { fetchData(); } - }, [addToast, props.action, props.coreStart.http, props.sourceUserName, dataSource.id]); + }, [addToast, props.action, props.coreStart.http, props.sourceUserName, dataSource]); const updateUserHandler = async () => { try { diff --git a/public/apps/configuration/panels/permission-list/permission-list.tsx b/public/apps/configuration/panels/permission-list/permission-list.tsx index d7237c7b5..41d1515ba 100644 --- a/public/apps/configuration/panels/permission-list/permission-list.tsx +++ b/public/apps/configuration/panels/permission-list/permission-list.tsx @@ -32,6 +32,7 @@ import { RIGHT_ALIGNMENT, EuiButtonEmpty, Query, + EuiLoadingContent, } from '@elastic/eui'; import { difference } from 'lodash'; import React, { @@ -63,6 +64,7 @@ import { generateResourceName } from '../../utils/resource-utils'; import { DocLinks } from '../../constants'; import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; import { DataSourceContext } from '../../app-router'; +import { AccessErrorComponent } from '../../access-error-component'; export function renderBooleanToCheckMark(value: boolean): React.ReactNode { return value ? : ''; @@ -188,6 +190,7 @@ export function PermissionList(props: AppDependencies) { const [permissionList, setPermissionList] = useState([]); const [actionGroupDict, setActionGroupDict] = useState>({}); const [errorFlag, setErrorFlag] = useState(false); + const [accessErrorFlag, setAccessErrorFlag] = useState(false); const [selection, setSelection] = React.useState([]); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({}); @@ -210,17 +213,22 @@ export function PermissionList(props: AppDependencies) { setActionGroupDict(actionGroups); setPermissionList(await mergeAllPermissions(actionGroups)); setErrorFlag(false); + setAccessErrorFlag(false); } catch (e) { console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } setErrorFlag(true); } finally { setLoading(false); } - }, [props.coreStart.http, dataSource.id]); + }, [props.coreStart.http, dataSource]); React.useEffect(() => { fetchData(); - }, [props.coreStart.http, fetchData, dataSource.id]); + }, [props.coreStart.http, fetchData, dataSource]); const handleDelete = async () => { const groupsToDelete: string[] = selection.map((r) => r.name); @@ -361,57 +369,68 @@ export function PermissionList(props: AppDependencies) {

Permissions

- - - - -

- Permissions - - {' '} - ({Query.execute(query || '', permissionList).length}) - -

-
- - Permissions are individual actions, such as cluster:admin/snapshot/restore, which lets - you restore snapshots. Action groups are reusable collections of permissions, such as - MANAGE_SNAPSHOTS, which lets you view, take, delete, and restore snapshots. You can - often meet your security needs using the default action groups, but you might find it - convenient to create your own. - -
- - - {actionsMenu} - {createActionGroupMenu} - - -
- - { - setQuery(arg.query); - return true; - }, - }} - selection={{ onSelectionChange: setSelection }} - sorting={{ sort: { field: 'type', direction: 'asc' } }} - error={errorFlag ? 'Load data failed, please check console log for more detail.' : ''} - isExpandable={true} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - message={showTableStatusMessage(loading, permissionList)} - /> - -
+ {loading ? ( + + ) : accessErrorFlag ? ( + + ) : ( + + + + +

+ Permissions + + {' '} + ({Query.execute(query || '', permissionList).length}) + +

+
+ + Permissions are individual actions, such as cluster:admin/snapshot/restore, which + lets you restore snapshots. Action groups are reusable collections of permissions, + such as MANAGE_SNAPSHOTS, which lets you view, take, delete, and restore snapshots. + You can often meet your security needs using the default action groups, but you + might find it convenient to create your own.{' '} + + +
+ + + {actionsMenu} + {createActionGroupMenu} + + +
+ + { + setQuery(arg.query); + return true; + }, + }} + selection={{ onSelectionChange: setSelection }} + sorting={{ sort: { field: 'type', direction: 'asc' } }} + error={errorFlag ? 'Load data failed, please check console log for more detail.' : ''} + isExpandable={true} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + message={showTableStatusMessage(loading, permissionList)} + /> + +
+ )} {editModal} {deleteConfirmModal} diff --git a/public/apps/configuration/panels/permission-list/test/__snapshots__/permission-list.test.tsx.snap b/public/apps/configuration/panels/permission-list/test/__snapshots__/permission-list.test.tsx.snap new file mode 100644 index 000000000..4b86b4973 --- /dev/null +++ b/public/apps/configuration/panels/permission-list/test/__snapshots__/permission-list.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Permission list page AccessError component should load access error component 1`] = ` + + + + +

+ Permissions +

+
+
+ + +
+`; diff --git a/public/apps/configuration/panels/permission-list/test/permission-list.test.tsx b/public/apps/configuration/panels/permission-list/test/permission-list.test.tsx index 0d72b6ea4..b875b90f6 100644 --- a/public/apps/configuration/panels/permission-list/test/permission-list.test.tsx +++ b/public/apps/configuration/panels/permission-list/test/permission-list.test.tsx @@ -230,4 +230,27 @@ describe('Permission list page ', () => { expect(component.find('#duplicate').prop('disabled')).toBe(false); }); }); + + describe('AccessError component', () => { + const mockCoreStart = { + http: 1, + }; + let component; + beforeEach(() => { + fetchActionGroups.mockRejectedValueOnce({ response: { status: 403 } }); + jest.spyOn(React, 'useEffect').mockImplementationOnce((f) => f()); + component = shallow( + + ); + }); + + it('should load access error component', () => { + expect(component).toMatchSnapshot(); + }); + }); }); diff --git a/public/apps/configuration/panels/role-edit/role-edit.tsx b/public/apps/configuration/panels/role-edit/role-edit.tsx index 42fb8c2a9..384a2e95c 100644 --- a/public/apps/configuration/panels/role-edit/role-edit.tsx +++ b/public/apps/configuration/panels/role-edit/role-edit.tsx @@ -114,7 +114,7 @@ export function RoleEdit(props: RoleEditDeps) { fetchData(); } - }, [addToast, props.action, props.coreStart.http, props.sourceRoleName, dataSource.id]); + }, [addToast, props.action, props.coreStart.http, props.sourceRoleName, dataSource]); const [actionGroups, setActionGroups] = useState>([]); React.useEffect(() => { @@ -129,7 +129,7 @@ export function RoleEdit(props: RoleEditDeps) { }; fetchActionGroupNames(); - }, [addToast, props.coreStart.http, dataSource.id]); + }, [addToast, props.coreStart.http, dataSource]); const [tenantNames, setTenantNames] = React.useState([]); React.useEffect(() => { @@ -143,7 +143,7 @@ export function RoleEdit(props: RoleEditDeps) { }; fetchTenantNames(); - }, [addToast, props.coreStart.http, dataSource.id]); + }, [addToast, props.coreStart.http, dataSource]); const updateRoleHandler = async () => { try { diff --git a/public/apps/configuration/panels/role-list.tsx b/public/apps/configuration/panels/role-list.tsx index 72b8a5837..a0e64d088 100644 --- a/public/apps/configuration/panels/role-list.tsx +++ b/public/apps/configuration/panels/role-list.tsx @@ -30,6 +30,7 @@ import { EuiButtonEmpty, EuiSearchBarProps, Query, + EuiLoadingContent, } from '@elastic/eui'; import { difference } from 'lodash'; import { AppDependencies } from '../../types'; @@ -56,6 +57,7 @@ import { useContextMenuState } from '../utils/context-menu'; import { DocLinks } from '../constants'; import { DataSourceContext } from '../app-router'; import { SecurityPluginTopNavMenu } from '../top-nav-menu'; +import { AccessErrorComponent } from '../access-error-component'; const columns: Array> = [ { @@ -107,6 +109,7 @@ export function RoleList(props: AppDependencies) { const [errorFlag, setErrorFlag] = React.useState(false); const [selection, setSelection] = React.useState([]); const [loading, setLoading] = useState(false); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); const { dataSource, setDataSource } = useContext(DataSourceContext)!; React.useEffect(() => { @@ -118,8 +121,13 @@ export function RoleList(props: AppDependencies) { const processedData = transformRoleData(rawRoleData, rawRoleMappingData); setRoleData(processedData); setErrorFlag(false); + setAccessErrorFlag(false); } catch (e) { console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } setErrorFlag(true); } finally { setLoading(false); @@ -127,7 +135,7 @@ export function RoleList(props: AppDependencies) { }; fetchData(); - }, [props.coreStart.http, dataSource.id]); + }, [props.coreStart.http, dataSource]); const handleDelete = async () => { const rolesToDelete: string[] = selection.map((r) => r.roleName); @@ -265,58 +273,64 @@ export function RoleList(props: AppDependencies) {

Roles

- - - - -

- Roles - - {' '} - ({Query.execute(query || '', roleData).length}) - -

-
- - Roles are the core way of controlling access to your cluster. Roles contain any - combination of cluster-wide permission, index-specific permissions, document- and - field-level security, and tenants. Then you map users to these roles so that users - gain those permissions. - -
- - - {actionsMenu} - - - Create role - - - - -
- - - - {deleteConfirmModal} -
+ {loading ? ( + + ) : accessErrorFlag ? ( + + ) : ( + + + + +

+ Roles + + {' '} + ({Query.execute(query || '', roleData).length}) + +

+
+ + Roles are the core way of controlling access to your cluster. Roles contain any + combination of cluster-wide permission, index-specific permissions, document- and + field-level security, and tenants. Then you map users to these roles so that users + gain those permissions. + +
+ + + {actionsMenu} + + + Create role + + + + +
+ + + + {deleteConfirmModal} +
+ )} ); } diff --git a/public/apps/configuration/panels/role-mapping/role-edit-mapped-user.tsx b/public/apps/configuration/panels/role-mapping/role-edit-mapped-user.tsx index 8bfc51b01..65e384275 100644 --- a/public/apps/configuration/panels/role-mapping/role-edit-mapped-user.tsx +++ b/public/apps/configuration/panels/role-mapping/role-edit-mapped-user.tsx @@ -86,7 +86,7 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { }; fetchData(); - }, [addToast, props.coreStart.http, props.roleName, dataSource.id]); + }, [addToast, props.coreStart.http, props.roleName, dataSource]); React.useEffect(() => { const fetchInternalUserNames = async () => { @@ -101,7 +101,7 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { }; fetchInternalUserNames(); - }, [addToast, props.coreStart.http, dataSource.id]); + }, [addToast, props.coreStart.http, dataSource]); const internalUserOptions = userNames.map(stringToComboBoxOption); const updateRoleMappingHandler = async () => { diff --git a/public/apps/configuration/panels/role-view/role-view.tsx b/public/apps/configuration/panels/role-view/role-view.tsx index caac1bfd7..0b29c3469 100644 --- a/public/apps/configuration/panels/role-view/role-view.tsx +++ b/public/apps/configuration/panels/role-view/role-view.tsx @@ -147,7 +147,7 @@ export function RoleView(props: RoleViewProps) { }; fetchData(); - }, [addToast, props.coreStart.http, props.roleName, props.prevAction, dataSource.id]); + }, [addToast, props.coreStart.http, props.roleName, props.prevAction, dataSource]); const handleRoleMappingDelete = async () => { try { diff --git a/public/apps/configuration/panels/service-account-list.tsx b/public/apps/configuration/panels/service-account-list.tsx index 4ed2d7a5f..e689e9f26 100644 --- a/public/apps/configuration/panels/service-account-list.tsx +++ b/public/apps/configuration/panels/service-account-list.tsx @@ -20,6 +20,7 @@ import { EuiFlexItem, EuiInMemoryTable, EuiLink, + EuiLoadingContent, EuiPageBody, EuiPageContent, EuiPageContentHeader, @@ -44,6 +45,7 @@ import { showTableStatusMessage } from '../utils/loading-spinner-utils'; import { buildHashUrl } from '../utils/url-builder'; import { LocalCluster } from '../app-router'; import { SecurityPluginTopNavMenu } from '../top-nav-menu'; +import { AccessErrorComponent } from '../access-error-component'; export function dictView(items: Dictionary) { if (isEmpty(items)) { @@ -94,8 +96,9 @@ export function getColumns(currentUsername: string) { export function ServiceAccountList(props: AppDependencies) { const [userData, setUserData] = React.useState([]); - const [errorFlag, setErrorFlag] = React.useState(false); const [selection, setSelection] = React.useState([]); + const [errorFlag, setErrorFlag] = React.useState(false); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); const [currentUsername, setCurrentUsername] = useState(''); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(null); @@ -111,8 +114,14 @@ export function ServiceAccountList(props: AppDependencies) { ); setCurrentUsername((await getAuthInfo(props.coreStart.http)).user_name); setUserData(await userDataPromise); + setErrorFlag(false); + setAccessErrorFlag(false); } catch (e) { console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } setErrorFlag(true); } finally { setLoading(false); @@ -176,58 +185,64 @@ export function ServiceAccountList(props: AppDependencies) {

Service accounts

- - - - -

- Service accounts - - {' '} - ({Query.execute(query || '', userData).length}) - -

-
- - Here you have a list of special accounts that represent services like extensions, - plugins or other third party applications. You can map an account to a role from - Roles - “Manage mapping” - - -
- - - {actionsMenu} - - -
- - { - setQuery(arg.query); - return true; - }, - }} - // @ts-ignore - selection={{ onSelectionChange: setSelection }} - sorting - error={ - errorFlag ? 'Load data failed, please check the console log for more details.' : '' - } - message={showTableStatusMessage(loading, userData)} - /> - -
+ {loading ? ( + + ) : accessErrorFlag ? ( + + ) : ( + + + + +

+ Service accounts + + {' '} + ({Query.execute(query || '', userData).length}) + +

+
+ + Here you have a list of special accounts that represent services like extensions, + plugins or other third party applications. You can map an account to a role from + Roles + “Manage mapping” + + +
+ + + {actionsMenu} + + +
+ + { + setQuery(arg.query); + return true; + }, + }} + // @ts-ignore + selection={{ onSelectionChange: setSelection }} + sorting + error={ + errorFlag ? 'Load data failed, please check the console log for more details.' : '' + } + message={showTableStatusMessage(loading, userData)} + /> + +
+ )} ); } diff --git a/public/apps/configuration/panels/tenant-list/configure_tab1.tsx b/public/apps/configuration/panels/tenant-list/configure_tab1.tsx index 2430ce1ac..b38f0a098 100644 --- a/public/apps/configuration/panels/tenant-list/configure_tab1.tsx +++ b/public/apps/configuration/panels/tenant-list/configure_tab1.tsx @@ -32,6 +32,7 @@ import { EuiBottomBar, EuiComboBox, EuiIcon, + EuiLoadingContent, } from '@elastic/eui'; import React, { ReactNode, useState } from 'react'; import { SaveChangesModalGenerator } from './save_changes_modal'; @@ -50,6 +51,8 @@ import { } from '../../utils/toast-utils'; import { getDashboardsInfo } from '../../../../utils/dashboards-info-utils'; import { LOCAL_CLUSTER_ID } from '../../../../../common'; +import { AccessErrorComponent } from '../../access-error-component'; +import { LocalCluster } from '../../app-router'; export function ConfigureTab1(props: AppDependencies) { const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(false); @@ -76,6 +79,8 @@ export function ConfigureTab1(props: AppDependencies) { const [toasts, addToast, removeToast] = useToastState(); const [selectedComboBoxOptions, setSelectedComboBoxOptions] = useState(); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); + const [loading, setLoading] = React.useState(false); const discardChangesFunction = async () => { await setUpdatedConfiguration(originalConfiguration); @@ -176,28 +181,38 @@ export function ConfigureTab1(props: AppDependencies) { React.useEffect(() => { const fetchData = async () => { try { + setLoading(true); + const dashboardsInfo = await getDashboardsInfo(props.coreStart.http); + const { + multitenancy_enabled: multitenancyEnabled = false, + private_tenant_enabled: privateTenantEnabled = false, + default_tenant: defaultTenant = '', + } = dashboardsInfo; await setOriginalConfiguration({ - multitenancy_enabled: (await getDashboardsInfo(props.coreStart.http)) - .multitenancy_enabled, - private_tenant_enabled: (await getDashboardsInfo(props.coreStart.http)) - .private_tenant_enabled, - default_tenant: (await getDashboardsInfo(props.coreStart.http)).default_tenant, + multitenancy_enabled: multitenancyEnabled, + private_tenant_enabled: privateTenantEnabled, + default_tenant: defaultTenant, }); await setUpdatedConfiguration({ - multitenancy_enabled: (await getDashboardsInfo(props.coreStart.http)) - .multitenancy_enabled, - private_tenant_enabled: (await getDashboardsInfo(props.coreStart.http)) - .private_tenant_enabled, - default_tenant: (await getDashboardsInfo(props.coreStart.http)).default_tenant, + multitenancy_enabled: multitenancyEnabled, + private_tenant_enabled: privateTenantEnabled, + default_tenant: defaultTenant, }); const rawTenantData = await fetchTenants(props.coreStart.http, LOCAL_CLUSTER_ID); const processedTenantData = transformTenantData(rawTenantData); setTenantData(processedTenantData); + setAccessErrorFlag(false); } catch (e) { // TODO: switch to better error display. console.error(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } + } finally { + setLoading(false); } }; fetchData(); @@ -306,6 +321,19 @@ export function ConfigureTab1(props: AppDependencies) { The private tenant is disabled. Select another default tenant. ); + + if (loading) { + return ; + } + if (accessErrorFlag) { + return ( + + ); + } + return ( <> diff --git a/public/apps/configuration/panels/tenant-list/manage_tab.tsx b/public/apps/configuration/panels/tenant-list/manage_tab.tsx index 79bcbe908..544aff698 100644 --- a/public/apps/configuration/panels/tenant-list/manage_tab.tsx +++ b/public/apps/configuration/panels/tenant-list/manage_tab.tsx @@ -34,6 +34,7 @@ import { EuiIcon, EuiConfirmModal, EuiCallOut, + EuiLoadingContent, } from '@elastic/eui'; import React, { ReactNode, useState, useCallback } from 'react'; import { difference } from 'lodash'; @@ -72,11 +73,13 @@ import { LocalCluster, getBreadcrumbs } from '../../app-router'; import { buildUrl } from '../../utils/url-builder'; import { CrossPageToast } from '../../cross-page-toast'; import { getDashboardsInfo } from '../../../../utils/dashboards-info-utils'; +import { AccessErrorComponent } from '../../access-error-component'; export function ManageTab(props: AppDependencies) { const setGlobalBreadcrumbs = flow(getBreadcrumbs, props.coreStart.chrome.setBreadcrumbs); const [tenantData, setTenantData] = React.useState([]); const [errorFlag, setErrorFlag] = React.useState(false); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); const [selection, setSelection] = React.useState([]); const [currentTenant, setCurrentTenant] = useState(''); const [currentUsername, setCurrentUsername] = useState(''); @@ -90,6 +93,7 @@ export function ManageTab(props: AppDependencies) { const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(false); const [isPrivateTenantEnabled, setIsPrivateTenantEnabled] = useState(false); const [dashboardsDefaultTenant, setDashboardsDefaultTenant] = useState(''); + const [isModalVisible, setIsModalVisible] = useState(false); const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; const { http } = props.coreStart; @@ -108,8 +112,13 @@ export function ManageTab(props: AppDependencies) { setIsMultiTenancyEnabled(tenancyConfig.multitenancy_enabled); setIsPrivateTenantEnabled(tenancyConfig.private_tenant_enabled); setDashboardsDefaultTenant(tenancyConfig.default_tenant); + setErrorFlag(false); } catch (e) { console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } setErrorFlag(true); } finally { setLoading(false); @@ -163,8 +172,6 @@ export function ManageTab(props: AppDependencies) { } }; - const [isModalVisible, setIsModalVisible] = useState(false); - const closeModal = () => setIsModalVisible(false); const showModal = () => setIsModalVisible(true); @@ -484,6 +491,17 @@ export function ManageTab(props: AppDependencies) { ); }; + if (loading) { + return ; + } + if (accessErrorFlag) { + return ( + + ); + } /* eslint-disable */ return ( <> diff --git a/public/apps/configuration/panels/tenant-list/tenant-list.tsx b/public/apps/configuration/panels/tenant-list/tenant-list.tsx index bc6aa71b8..c624adcbe 100644 --- a/public/apps/configuration/panels/tenant-list/tenant-list.tsx +++ b/public/apps/configuration/panels/tenant-list/tenant-list.tsx @@ -44,9 +44,8 @@ export function TenantList(props: TenantListProps) { React.useEffect(() => { const fetchData = async () => { try { - await setIsMultiTenancyEnabled( - (await getDashboardsInfo(props.coreStart.http)).multitenancy_enabled - ); + const dashboardsInfo = await getDashboardsInfo(props.coreStart.http); + setIsMultiTenancyEnabled(dashboardsInfo?.multitenancy_enabled || false); } catch (e) { console.log(e); } diff --git a/public/apps/configuration/panels/tenant-list/test/__snapshots__/tenant-list.test.tsx.snap b/public/apps/configuration/panels/tenant-list/test/__snapshots__/tenant-list.test.tsx.snap index c817212aa..3f40c1b34 100644 --- a/public/apps/configuration/panels/tenant-list/test/__snapshots__/tenant-list.test.tsx.snap +++ b/public/apps/configuration/panels/tenant-list/test/__snapshots__/tenant-list.test.tsx.snap @@ -1,5 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Tenant list AccessError component should load access error component: configure tab 1`] = ` + +`; + +exports[`Tenant list AccessError component should load access error component: manage tab 1`] = ` + +`; + exports[`Tenant list Action menu click Duplicate click 1`] = ` @@ -138,7 +152,7 @@ exports[`Tenant list Action menu click Duplicate click 1`] = ` }, ] } - error="Load data failed, please check console log for more detail." + error="" itemId="tenant" items={ Array [ @@ -326,7 +340,7 @@ exports[`Tenant list Action menu click Edit click 1`] = ` }, ] } - error="Load data failed, please check console log for more detail." + error="" itemId="tenant" items={ Array [ diff --git a/public/apps/configuration/panels/tenant-list/test/tenant-list.test.tsx b/public/apps/configuration/panels/tenant-list/test/tenant-list.test.tsx index e6f5bfbf3..ca4081fce 100644 --- a/public/apps/configuration/panels/tenant-list/test/tenant-list.test.tsx +++ b/public/apps/configuration/panels/tenant-list/test/tenant-list.test.tsx @@ -21,6 +21,7 @@ import { useDeleteConfirmState } from '../../../utils/delete-confirm-modal-utils import { Tenant } from '../../../types'; import { TenantEditModal } from '../edit-modal'; import { getDashboardsInfo } from '../../../../../utils/dashboards-info-utils'; +import { ConfigureTab1 } from '../configure_tab1'; jest.mock('../../../utils/tenant-utils'); jest.mock('../../../../../utils/auth-info-utils'); @@ -267,7 +268,22 @@ describe('Tenant list', () => { }; it('edit and delete should be disabled when selected tenant is reserved', () => { - jest.spyOn(React, 'useState').mockImplementation(() => [[sampleReservedTenant], jest.fn()]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[sampleReservedTenant], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[sampleReservedTenant], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); const component = shallow( { }); it('All menues should be disabled when there is multiple tenant selected including reserved tenant', () => { + jest.spyOn(React, 'useState').mockRestore(); jest .spyOn(React, 'useState') - .mockImplementation(() => [[sampleReservedTenant, sampleCustomTenant1], jest.fn()]); + .mockImplementationOnce(() => [[sampleReservedTenant, sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[sampleReservedTenant, sampleReservedTenant], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); const component = shallow( { expect(component.find('#delete').prop('disabled')).toBe(true); }); - it('All menues should be disabled except delete when there is multiple custom tenant selected', () => { + it('All menus should be disabled except delete when there is multiple custom tenant selected', () => { + jest.spyOn(React, 'useState').mockRestore(); jest .spyOn(React, 'useState') - .mockImplementation(() => [[sampleCustomTenant1, sampleCustomTenant2], jest.fn()]); + .mockImplementationOnce(() => [[sampleCustomTenant1, sampleCustomTenant2], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[sampleCustomTenant1, sampleCustomTenant2], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); const component = shallow( { }; beforeEach(() => { - jest.spyOn(React, 'useState').mockImplementation(() => [[sampleCustomTenant1], jest.fn()]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); + component = shallow( { }); it('Edit click', () => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); component.find('#edit').simulate('click'); expect(component).toMatchSnapshot(); }); it('Duplicate click', () => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[sampleCustomTenant1], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); component.find('#duplicate').simulate('click'); expect(component).toMatchSnapshot(); }); @@ -368,4 +458,50 @@ describe('Tenant list', () => { expect(mockTenantUtils.selectTenant).toHaveBeenCalled(); }); }); + + describe('AccessError component', () => { + let component; + beforeEach(() => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [true, jest.fn()]) + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); + }); + + it('should load access error component: manage tab', () => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + it('should load access error component: configure tab', () => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + }); }); diff --git a/public/apps/configuration/panels/test/__snapshots__/role-list.test.tsx.snap b/public/apps/configuration/panels/test/__snapshots__/role-list.test.tsx.snap index 346ab0454..4e4035617 100644 --- a/public/apps/configuration/panels/test/__snapshots__/role-list.test.tsx.snap +++ b/public/apps/configuration/panels/test/__snapshots__/role-list.test.tsx.snap @@ -1,5 +1,169 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Role list AccessError component should load access error component 1`] = ` + + + + +

+ Roles +

+
+
+ + + + +

+ Roles + + + ( + 0 + ) + +

+
+ + Roles are the core way of controlling access to your cluster. Roles contain any combination of cluster-wide permission, index-specific permissions, document- and field-level security, and tenants. Then you map users to these roles so that users gain those permissions. + + +
+ + + + + Edit + + + Duplicate + + + Delete + + + + + Create role + + + + +
+ + + +
+
+`; + exports[`Role list Render columns render Customization column 1`] = ` + + + +

+ Service accounts +

+
+
+ +
+`; diff --git a/public/apps/configuration/panels/test/__snapshots__/user-list.test.tsx.snap b/public/apps/configuration/panels/test/__snapshots__/user-list.test.tsx.snap new file mode 100644 index 000000000..82f4af425 --- /dev/null +++ b/public/apps/configuration/panels/test/__snapshots__/user-list.test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`User list AccessError component should load access error component 1`] = ` + + + + +

+ Internal users +

+
+
+ +
+`; diff --git a/public/apps/configuration/panels/test/role-list.test.tsx b/public/apps/configuration/panels/test/role-list.test.tsx index becbfe806..c5f693633 100644 --- a/public/apps/configuration/panels/test/role-list.test.tsx +++ b/public/apps/configuration/panels/test/role-list.test.tsx @@ -190,7 +190,13 @@ describe('Role list', () => { backendRoles: [], }; beforeEach(() => { - jest.spyOn(React, 'useState').mockImplementation(() => [[mockRoleListingData], jest.fn()]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[mockRoleListingData], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[mockRoleListingData], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); component = shallow( { }); describe('Render columns', () => { + beforeEach(() => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]); + }); it('render role name column', () => { const wrapper = shallow( { expect(result).toMatchSnapshot(); }); }); + + describe('AccessError component', () => { + let component; + beforeEach(() => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [true, jest.fn()]); + component = shallow( + + ); + }); + + it('should load access error component', () => { + expect(component).toMatchSnapshot(); + }); + }); }); diff --git a/public/apps/configuration/panels/test/service-account-list.test.tsx b/public/apps/configuration/panels/test/service-account-list.test.tsx index d5407aeae..6e33a46be 100644 --- a/public/apps/configuration/panels/test/service-account-list.test.tsx +++ b/public/apps/configuration/panels/test/service-account-list.test.tsx @@ -20,7 +20,9 @@ import { EMPTY_FIELD_VALUE } from '../../ui-constants'; import { getUserList, InternalUsersListing } from '../../utils/internal-user-list-utils'; import { dictView, getColumns, ServiceAccountList } from '../service-account-list'; -jest.mock('../../utils/internal-user-list-utils'); +jest.mock('../../utils/internal-user-list-utils', () => ({ + getUserList: jest.fn(), +})); jest.mock('../../../../utils/auth-info-utils', () => ({ getAuthInfo: jest.fn().mockReturnValue({ user_name: 'user' }), })); @@ -35,7 +37,7 @@ import { buildHashUrl } from '../../utils/url-builder'; import { Action } from '../../types'; import { ResourceType } from '../../../../../common'; -describe('User list', () => { +describe('Service Account list', () => { describe('dictView', () => { it('- empty', () => { const result = dictView({}); @@ -131,7 +133,7 @@ describe('User list', () => { }); }); - describe('Action menu click', () => { + describe('Action menu Component', () => { const mockCoreStart = { http: { basePath: { @@ -140,13 +142,21 @@ describe('User list', () => { }, }; let component; - const mockUserListingData: InternalUsersListing = { + const mockSAListData: InternalUsersListing = { username: 'user_1', attributes: { service: 'true' }, backend_roles: ['backend_role1'], }; beforeEach(() => { - jest.spyOn(React, 'useState').mockImplementation(() => [[mockUserListingData], jest.fn()]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[mockSAListData], jest.fn()]) + .mockImplementationOnce(() => [[mockSAListData], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]); component = shallow( { it('Edit click', () => { component.find('[data-test-subj="edit"]').simulate('click'); expect(window.location.hash).toBe( - buildHashUrl(ResourceType.users, Action.edit, mockUserListingData.username) + buildHashUrl(ResourceType.users, Action.edit, mockSAListData.username) ); }); it('Duplicate click', () => { component.find('[data-test-subj="duplicate"]').simulate('click'); expect(window.location.hash).toBe( - buildHashUrl(ResourceType.users, Action.duplicate, mockUserListingData.username) + buildHashUrl(ResourceType.users, Action.duplicate, mockSAListData.username) + ); + }); + }); + + describe('AccessError component', () => { + let component; + const mockCoreStart = { + http: 1, + }; + beforeEach(() => { + getUserList.mockRejectedValue({ response: { status: 403 } }); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [true, jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]); + }); + + it('should load access error component', () => { + component = shallow( + ); + expect(component).toMatchSnapshot(); }); }); }); diff --git a/public/apps/configuration/panels/test/user-list.test.tsx b/public/apps/configuration/panels/test/user-list.test.tsx index 2e96573f5..980e8d21c 100644 --- a/public/apps/configuration/panels/test/user-list.test.tsx +++ b/public/apps/configuration/panels/test/user-list.test.tsx @@ -202,7 +202,16 @@ describe('User list', () => { backend_roles: ['backend_role1'], }; beforeEach(() => { - jest.spyOn(React, 'useState').mockImplementation(() => [[mockUserListingData], jest.fn()]); + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[mockUserListingData], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[mockUserListingData], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]); component = shallow( { ); }); }); + + describe('AccessError component', () => { + const mockCoreStart = { + http: { + basePath: { + serverBasePath: '', + }, + }, + }; + let component; + beforeEach(() => { + jest.spyOn(React, 'useState').mockRestore(); + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[true], jest.fn()]) + .mockImplementationOnce(() => [[], jest.fn()]) + .mockImplementationOnce(() => ['', jest.fn()]) + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [null, jest.fn()]); + component = shallow( + + ); + }); + + it('should load access error component', () => { + expect(component).toMatchSnapshot(); + }); + }); }); diff --git a/public/apps/configuration/panels/user-list.tsx b/public/apps/configuration/panels/user-list.tsx index 100cef0e0..4e838fe5b 100644 --- a/public/apps/configuration/panels/user-list.tsx +++ b/public/apps/configuration/panels/user-list.tsx @@ -21,6 +21,7 @@ import { EuiFlexItem, EuiInMemoryTable, EuiLink, + EuiLoadingContent, EuiPageBody, EuiPageContent, EuiPageContentHeader, @@ -50,6 +51,7 @@ import { showTableStatusMessage } from '../utils/loading-spinner-utils'; import { buildHashUrl } from '../utils/url-builder'; import { DataSourceContext } from '../app-router'; import { SecurityPluginTopNavMenu } from '../top-nav-menu'; +import { AccessErrorComponent } from '../access-error-component'; export function dictView(items: Dictionary) { if (isEmpty(items)) { @@ -101,6 +103,7 @@ export function getColumns(currentUsername: string) { export function UserList(props: AppDependencies) { const [userData, setUserData] = React.useState([]); const [errorFlag, setErrorFlag] = React.useState(false); + const [accessErrorFlag, setAccessErrorFlag] = React.useState(false); const [selection, setSelection] = React.useState([]); const [currentUsername, setCurrentUsername] = useState(''); const [loading, setLoading] = useState(false); @@ -119,8 +122,13 @@ export function UserList(props: AppDependencies) { setCurrentUsername((await getAuthInfo(props.coreStart.http)).user_name); setUserData(await userDataPromise); setErrorFlag(false); + setAccessErrorFlag(false); } catch (e) { console.log(e); + // requests with existing credentials but insufficient permissions result in 403, remote data-source requests with non-existing credentials result in 400 + if (e.response && [400, 403].includes(e.response.status)) { + setAccessErrorFlag(true); + } setErrorFlag(true); } finally { setLoading(false); @@ -128,7 +136,7 @@ export function UserList(props: AppDependencies) { }; fetchData(); - }, [props.coreStart.http, dataSource.id]); + }, [props.coreStart.http, dataSource]); const handleDelete = async () => { const usersToDelete: string[] = selection.map((r) => r.username); @@ -213,67 +221,73 @@ export function UserList(props: AppDependencies) {

Internal users

- - - - -

- Internal users - - {' '} - ({Query.execute(query || '', userData).length}) - -

-
- - The Security plugin includes an internal user database. Use this database in place of, - or in addition to, an external authentication system such as LDAP server or Active - Directory. You can map an user account to a role from{' '} - Roles - . First, click into the detail page of the role. Then, under “Mapped users”, click - “Manage mapping” - -
- - - {actionsMenu} - - - Create user account - - - - -
- - { - setQuery(arg.query); - return true; - }, - }} - // @ts-ignore - selection={{ onSelectionChange: setSelection }} - sorting - error={errorFlag ? 'Load data failed, please check console log for more detail.' : ''} - message={showTableStatusMessage(loading, userData)} - /> - - {deleteConfirmModal} -
+ {loading ? ( + + ) : accessErrorFlag ? ( + + ) : ( + + + + +

+ Internal users + + {' '} + ({Query.execute(query || '', userData).length}) + +

+
+ + The Security plugin includes an internal user database. Use this database in place + of, or in addition to, an external authentication system such as LDAP server or + Active Directory. You can map an user account to a role from{' '} + Roles + . First, click into the detail page of the role. Then, under “Mapped users”, click + “Manage mapping” + +
+ + + {actionsMenu} + + + Create user account + + + + +
+ + { + setQuery(arg.query); + return true; + }, + }} + // @ts-ignore + selection={{ onSelectionChange: setSelection }} + sorting + error={errorFlag ? 'Load data failed, please check console log for more detail.' : ''} + message={showTableStatusMessage(loading, userData)} + /> + + {deleteConfirmModal} +
+ )} ); } diff --git a/public/apps/configuration/test/__snapshots__/access-error-component.test.tsx.snap b/public/apps/configuration/test/__snapshots__/access-error-component.test.tsx.snap new file mode 100644 index 000000000..48012118e --- /dev/null +++ b/public/apps/configuration/test/__snapshots__/access-error-component.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccessErrorComponent should display custom message prefix once loading is complete 1`] = ` + + Custom message prefix for Test-cluster. + +`; + +exports[`AccessErrorComponent should display default message prefix once loading is complete 1`] = ` + + You do not have permissions to view this data for Test-cluster. + +`; + +exports[`AccessErrorComponent should render loading when loading the content 1`] = ``; diff --git a/public/apps/configuration/test/access-error-component.test.tsx b/public/apps/configuration/test/access-error-component.test.tsx new file mode 100644 index 000000000..20572ce60 --- /dev/null +++ b/public/apps/configuration/test/access-error-component.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { AccessErrorComponent } from '../access-error-component'; + +describe('AccessErrorComponent', () => { + it('should render loading when loading the content', () => { + const props = { + dataSourceLabel: 'Test-cluster', + loading: true, + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should display default message prefix once loading is complete', () => { + const props = { + loading: false, + dataSourceLabel: 'Test-cluster', + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should display custom message prefix once loading is complete', () => { + const props = { + loading: false, + dataSourceLabel: 'Test-cluster', + message: 'Custom message prefix', + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/public/apps/configuration/top-nav-menu.tsx b/public/apps/configuration/top-nav-menu.tsx index 669a6d805..5e5e51893 100644 --- a/public/apps/configuration/top-nav-menu.tsx +++ b/public/apps/configuration/top-nav-menu.tsx @@ -54,14 +54,21 @@ export const SecurityPluginTopNavMenu = React.memo( savedObjects: coreStart.savedObjects.client, notifications: coreStart.notifications, activeOption: - selectedDataSource.id || selectedDataSource.label ? [selectedDataSource] : undefined, + selectedDataSource && (selectedDataSource.id || selectedDataSource.label) + ? [selectedDataSource] + : undefined, onSelectedDataSources: wrapSetDataSourceWithUpdateUrl, fullWidth: true, }} /> ) : null; }, - (prevProps, newProps) => - prevProps.selectedDataSource.id === newProps.selectedDataSource.id && - prevProps.dataSourcePickerReadOnly === newProps.dataSourcePickerReadOnly + (prevProps, newProps) => { + return ( + prevProps.selectedDataSource && + newProps.selectedDataSource && + prevProps.selectedDataSource.id === newProps.selectedDataSource.id && + prevProps.dataSourcePickerReadOnly === newProps.dataSourcePickerReadOnly + ); + } ); diff --git a/public/plugin.ts b/public/plugin.ts index ca9798a94..75a983ab9 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -87,6 +87,7 @@ export class SecurityPlugin deps: SecurityPluginSetupDependencies ): Promise { const apiPermission = await hasApiPermission(core); + const mdsEnabled = !!deps.dataSource?.dataSourceEnabled; const config = this.initializerContext.config.get(); @@ -96,7 +97,7 @@ export class SecurityPlugin (config.readonly_mode?.roles || DEFAULT_READONLY_ROLES).includes(role) ); - if (apiPermission) { + if (mdsEnabled || apiPermission) { core.application.register({ id: PLUGIN_NAME, title: 'Security', diff --git a/public/utils/datasource-utils.ts b/public/utils/datasource-utils.ts index 84b7cc135..638c515eb 100644 --- a/public/utils/datasource-utils.ts +++ b/public/utils/datasource-utils.ts @@ -27,7 +27,12 @@ export function getClusterInfo(dataSourceEnabled: boolean, cluster: DataSourceOp export function getDataSourceFromUrl(): DataSourceOption { const urlParams = new URLSearchParams(window.location.search); const dataSourceParam = (urlParams && urlParams.get(DATASOURCEURLKEY)) || '{}'; - return JSON.parse(dataSourceParam); + // following block is needed if the dataSource param is set to non-JSON value, say 'undefined' + try { + return JSON.parse(dataSourceParam); + } catch (e) { + return JSON.parse('{}'); // Return an empty object or some default value if parsing fails + } } export function setDataSourceInUrl(dataSource: DataSourceOption) { diff --git a/public/utils/test/datasource-utils.test.ts b/public/utils/test/datasource-utils.test.ts index 040166ec8..33aef7905 100644 --- a/public/utils/test/datasource-utils.test.ts +++ b/public/utils/test/datasource-utils.test.ts @@ -69,4 +69,13 @@ describe('Tests datasource utils', () => { 'http://localhost:5601/app/security-dashboards-plugin?dataSource=%7B%22id%22%3A%22%22%2C%22label%22%3A%22Local+cluster%22%7D#/auth' ); }); + + it('Tests getting the datasource from the url with undefined dataSource', () => { + const mockSearchUndefinedDataSource = '?dataSource=undefined'; + Object.defineProperty(window, 'location', { + value: { search: mockSearchUndefinedDataSource }, + writable: true, + }); + expect(getDataSourceFromUrl()).toEqual({}); + }); });