From 3cff1254d108a6deba551a8bef2d9273f28a213d Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 17 Apr 2023 18:32:21 -0700 Subject: [PATCH] Dynamic tenancy configurations (#1394) (#1404) * Dynamic multitenancy feature. Signed-off-by: Abhi Kalra (cherry picked from commit dc3c7451ae8bf4b3b9cb753aeab938e184fba476) --- .github/workflows/cypress-test.yml | 2 + common/index.ts | 1 + public/apps/account/account-nav-button.tsx | 30 +- public/apps/account/tenant-switch-panel.tsx | 18 +- .../account/test/account-nav-button.test.tsx | 31 +- .../account/test/tenant-switch-panel.test.tsx | 90 ++- public/apps/configuration/app-router.tsx | 18 +- public/apps/configuration/constants.tsx | 2 + .../apps/configuration/panels/get-started.tsx | 60 +- .../panels/role-view/tenants-panel.tsx | 4 +- .../panels/tenancy-config/types.tsx | 20 + .../panels/tenant-list/configure_tab1.tsx | 477 +++++++++++++++ .../panels/tenant-list/manage_tab.tsx | 553 ++++++++++++++++++ .../panels/tenant-list/save_changes_modal.tsx | 189 ++++++ .../panels/tenant-list/tenant-list.tsx | 456 ++++----------- .../__snapshots__/tenant-list.test.tsx.snap | 60 +- .../tenant-list/test/tenant-list.test.tsx | 58 +- .../__snapshots__/get-started.test.tsx.snap | 90 +++ public/apps/configuration/types.ts | 4 + .../apps/configuration/utils/request-utils.ts | 4 + .../utils/tenancy-config_util.tsx | 28 + .../apps/configuration/utils/tenant-utils.tsx | 35 +- .../utils/test/tenant-utils.test.tsx | 16 +- .../utils/test/toast-utils.test.tsx | 1 + .../apps/configuration/utils/toast-utils.tsx | 19 + public/apps/types.ts | 3 +- public/plugin.ts | 4 +- public/types.ts | 6 + public/utils/dashboards-info-utils.tsx | 29 + server/auth/types/authentication_type.ts | 20 +- server/auth/types/basic/routes.ts | 39 +- server/auth/user.ts | 3 + server/backend/opensearch_security_client.ts | 35 ++ server/backend/opensearch_security_plugin.ts | 14 + server/multitenancy/routes.ts | 31 + server/multitenancy/tenant_resolver.ts | 51 +- .../multitenancy/test/tenant_resolver.test.ts | 18 + server/routes/index.ts | 24 + 38 files changed, 2046 insertions(+), 497 deletions(-) create mode 100644 public/apps/configuration/panels/tenancy-config/types.tsx create mode 100644 public/apps/configuration/panels/tenant-list/configure_tab1.tsx create mode 100644 public/apps/configuration/panels/tenant-list/manage_tab.tsx create mode 100644 public/apps/configuration/panels/tenant-list/save_changes_modal.tsx create mode 100644 public/apps/configuration/utils/tenancy-config_util.tsx create mode 100644 public/utils/dashboards-info-utils.tsx diff --git a/.github/workflows/cypress-test.yml b/.github/workflows/cypress-test.yml index e4f1f40d8..a60ffcbc2 100644 --- a/.github/workflows/cypress-test.yml +++ b/.github/workflows/cypress-test.yml @@ -70,3 +70,5 @@ jobs: cd opensearch-dashboards-functional-test npm install cypress --save-dev yarn cypress:run-with-security-and-aggregation-view --browser chrome --spec "cypress/integration/plugins/security-dashboards-plugin/aggregation_view.js" + yarn cypress:run-with-security --browser chrome --spec "cypress/integration/plugins/security-dashboards-plugin/multi_tenancy.js" + yarn cypress:run-with-security --browser chrome --spec "cypress/integration/plugins/security-dashboards-plugin/default_tenant.js" diff --git a/common/index.ts b/common/index.ts index 430b74c0b..41dbb73dc 100644 --- a/common/index.ts +++ b/common/index.ts @@ -23,6 +23,7 @@ export const OPENDISTRO_SECURITY_ANONYMOUS = 'opendistro_security_anonymous'; export const API_PREFIX = '/api/v1'; export const CONFIGURATION_API_PREFIX = 'configuration'; export const API_ENDPOINT_AUTHINFO = API_PREFIX + '/auth/authinfo'; +export const API_ENDPOINT_DASHBOARDSINFO = API_PREFIX + '/auth/dashboardsinfo'; export const API_ENDPOINT_AUTHTYPE = API_PREFIX + '/auth/type'; export const LOGIN_PAGE_URI = '/app/' + APP_ID_LOGIN; export const CUSTOM_ERROR_PAGE_URI = '/app/' + APP_ID_CUSTOMERROR; diff --git a/public/apps/account/account-nav-button.tsx b/public/apps/account/account-nav-button.tsx index 001f69f02..6da1aec11 100644 --- a/public/apps/account/account-nav-button.tsx +++ b/public/apps/account/account-nav-button.tsx @@ -35,6 +35,7 @@ import { ClientConfigType } from '../../types'; import { LogoutButton } from './log-out-button'; import { resolveTenantName } from '../configuration/utils/tenant-utils'; import { getShouldShowTenantPopup, setShouldShowTenantPopup } from '../../utils/storage-utils'; +import { getDashboardsInfo } from '../../utils/dashboards-info-utils'; export function AccountNavButton(props: { coreStart: CoreStart; @@ -48,6 +49,7 @@ export function AccountNavButton(props: { const [modal, setModal] = React.useState(null); const horizontalRule = ; const username = props.username; + const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = React.useState(true); const showTenantSwitchPanel = useCallback( () => @@ -67,9 +69,23 @@ export function AccountNavButton(props: { ), [props.config, props.coreStart, props.tenant] ); + React.useEffect(() => { + const fetchData = async () => { + try { + setIsMultiTenancyEnabled( + (await getDashboardsInfo(props.coreStart.http)).multitenancy_enabled + ); + } catch (e) { + // TODO: switch to better error display. + console.error(e); + } + }; + + fetchData(); + }, [props.coreStart.http]); // Check if the tenant modal should be shown on load - if (props.config.multitenancy.enabled && getShouldShowTenantPopup()) { + if (isMultiTenancyEnabled && getShouldShowTenantPopup()) { setShouldShowTenantPopup(false); showTenantSwitchPanel(); } @@ -112,10 +128,14 @@ export function AccountNavButton(props: { > View roles and identities - {horizontalRule} - - Switch tenants - + {isMultiTenancyEnabled && ( + <> + {horizontalRule} + + Switch tenants + + + )} {props.isInternalUser && ( <> {horizontalRule} diff --git a/public/apps/account/tenant-switch-panel.tsx b/public/apps/account/tenant-switch-panel.tsx index 96d5b9659..2768d9eb0 100755 --- a/public/apps/account/tenant-switch-panel.tsx +++ b/public/apps/account/tenant-switch-panel.tsx @@ -17,7 +17,6 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiCheckbox, EuiComboBox, EuiComboBoxOptionOption, EuiModal, @@ -31,7 +30,7 @@ import { } from '@elastic/eui'; import { CoreStart } from 'opensearch-dashboards/public'; import { keys } from 'lodash'; -import React from 'react'; +import React, { useState } from 'react'; import { ClientConfigType } from '../../types'; import { RESOLVED_GLOBAL_TENANT, @@ -42,6 +41,7 @@ import { import { fetchAccountInfo } from './utils'; import { constructErrorMessageAndLog } from '../error-utils'; import { getSavedTenant, setSavedTenant } from '../../utils/storage-utils'; +import { getDashboardsInfo } from '../../utils/dashboards-info-utils'; interface TenantSwitchPanelProps { coreStart: CoreStart; @@ -65,6 +65,10 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { const [selectedCustomTenantOption, setSelectedCustomTenantOption] = React.useState< EuiComboBoxOptionOption[] >([]); + const [isPrivateEnabled, setIsPrivateEnabled] = useState( + props.config.multitenancy.tenants.enable_private + ); + const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(true); const setCurrentTenant = (currentRawTenantName: string, currentUserName: string) => { const resolvedTenantName = resolveTenantName(currentRawTenantName, currentUserName); @@ -84,7 +88,9 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { try { const accountInfo = await fetchAccountInfo(props.coreStart.http); setRoles(accountInfo.data.roles); - + const dashboardsInfo = await getDashboardsInfo(props.coreStart.http); + setIsMultiTenancyEnabled(dashboardsInfo.multitenancy_enabled); + setIsPrivateEnabled(dashboardsInfo.private_tenant_enabled); const tenantsInfo = accountInfo.data.tenants || {}; setTenants(keys(tenantsInfo)); @@ -122,16 +128,12 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { label: option, })); - const isMultiTenancyEnabled = props.config.multitenancy.enabled; - const isGlobalEnabled = props.config.multitenancy.tenants.enable_global; - const isPrivateEnabled = props.config.multitenancy.tenants.enable_private; - const DEFAULT_READONLY_ROLES = ['kibana_read_only']; const readonly = roles.some( (role) => props.config.readonly_mode?.roles.includes(role) || DEFAULT_READONLY_ROLES.includes(role) ); - + const isGlobalEnabled = props.config.multitenancy.tenants.enable_global; const shouldDisableGlobal = !isGlobalEnabled || !tenants.includes(GLOBAL_TENANT_KEY_NAME); const getGlobalDisabledInstruction = () => { if (!isGlobalEnabled) { diff --git a/public/apps/account/test/account-nav-button.test.tsx b/public/apps/account/test/account-nav-button.test.tsx index cd3127419..543a01087 100644 --- a/public/apps/account/test/account-nav-button.test.tsx +++ b/public/apps/account/test/account-nav-button.test.tsx @@ -17,12 +17,25 @@ import { shallow } from 'enzyme'; import React from 'react'; import { AccountNavButton } from '../account-nav-button'; import { getShouldShowTenantPopup, setShouldShowTenantPopup } from '../../../utils/storage-utils'; +import { getDashboardsInfo } from '../../../utils/dashboards-info-utils'; jest.mock('../../../utils/storage-utils', () => ({ getShouldShowTenantPopup: jest.fn(), setShouldShowTenantPopup: jest.fn(), })); +jest.mock('../../../utils/dashboards-info-utils', () => ({ + getDashboardsInfo: jest.fn().mockImplementation(() => { + return mockDashboardsInfo; + }), +})); + +const mockDashboardsInfo = { + multitenancy_enabled: true, + private_tenant_enabled: true, + default_tenant: '', +}; + describe('Account navigation button', () => { const mockCoreStart = { http: 1, @@ -49,6 +62,9 @@ describe('Account navigation button', () => { beforeEach(() => { useStateSpy.mockImplementation((init) => [init, setState]); + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); component = shallow( { }); it('renders', () => { + (getDashboardsInfo as jest.Mock).mockImplementationOnce(() => { + return mockDashboardsInfo; + }); expect(component).toMatchSnapshot(); }); it('should set modal when show popup is true', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); (getShouldShowTenantPopup as jest.Mock).mockReturnValueOnce(true); shallow( { }); it('should not set modal when show popup is true', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return { + multitenancy_enabled: false, + private_tenant_enabled: false, + default_tenant: '', + }; + }); (getShouldShowTenantPopup as jest.Mock).mockReturnValueOnce(true); shallow( { currAuthType={'dummy'} /> ); - expect(setState).toBeCalledTimes(0); + expect(setState).toBeCalledTimes(1); }); }); diff --git a/public/apps/account/test/tenant-switch-panel.test.tsx b/public/apps/account/test/tenant-switch-panel.test.tsx index 8e8375b04..bc1ac4c40 100755 --- a/public/apps/account/test/tenant-switch-panel.test.tsx +++ b/public/apps/account/test/tenant-switch-panel.test.tsx @@ -16,6 +16,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { fetchAccountInfo } from '../utils'; +import { getDashboardsInfo } from '../../../utils/dashboards-info-utils'; import { CUSTOM_TENANT_RADIO_ID, GLOBAL_TENANT_RADIO_ID, @@ -39,12 +40,24 @@ const mockAccountInfo = { }, }; +const mockDashboardsInfo = { + multitenancy_enabled: true, + private_tenant_enabled: true, + default_tenant: '', +}; + jest.mock('../utils', () => ({ fetchAccountInfo: jest.fn().mockImplementation(() => { return mockAccountInfo; }), })); +jest.mock('../../../utils/dashboards-info-utils', () => ({ + getDashboardsInfo: jest.fn().mockImplementation(() => { + return mockDashboardsInfo; + }), +})); + jest.mock('../../configuration/utils/tenant-utils', () => ({ ...jest.requireActual('../../configuration/utils/tenant-utils'), selectTenant: jest.fn(), @@ -76,9 +89,15 @@ describe('Account menu -tenant switch panel', () => { beforeEach(() => { useEffect.mockImplementationOnce((f) => f()); useState.mockImplementation((initialValue) => [initialValue, setState]); + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); }); it('fetch data when user requested tenant is Global', (done) => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); shallow( { }); it('fetch data when user requested tenant is Private', (done) => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); (fetchAccountInfo as jest.Mock).mockImplementationOnce(() => { return { data: { @@ -126,6 +148,9 @@ describe('Account menu -tenant switch panel', () => { }); it('fetch data when user requested tenant is Custom', (done) => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); (fetchAccountInfo as jest.Mock).mockImplementationOnce(() => { return { data: { @@ -155,6 +180,9 @@ describe('Account menu -tenant switch panel', () => { }); it('error occurred while fetching data', (done) => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); (fetchAccountInfo as jest.Mock).mockImplementationOnce(() => { throw new Error(); }); @@ -174,6 +202,9 @@ describe('Account menu -tenant switch panel', () => { }); it('handle modal close', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); const component = shallow( { }); it('Confirm button should be disabled when multitenancy is disabled in Config', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return { + multitenancy_enabled: false, + private_tenant_enabled: true, + default_tenant: '', + }; + }); const config = { multitenancy: { enabled: false, @@ -204,11 +242,16 @@ describe('Account menu -tenant switch panel', () => { config={config as any} /> ); - const confirmButton = component.find('[data-test-subj="confirm"]'); - expect(confirmButton.prop('disabled')).toBe(true); + process.nextTick(() => { + const confirmButton = component.find('[data-test-subj="confirm"]'); + expect(confirmButton.prop('disabled')).toBe(true); + }); }); it('selected radio id should be change on onChange event', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); const component = shallow( { }); it('should set error call out when tenant name is undefined', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); const component = shallow( { }); it('renders when both global and private tenant enabled', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); const component = shallow( { }); it('renders when global tenant disabled', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); const config = { multitenancy: { enabled: true, @@ -351,7 +403,14 @@ describe('Account menu -tenant switch panel', () => { expect(component).toMatchSnapshot(); }); - it('renders when private tenant disabled', (done) => { + it('renders when private tenant disabled', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return { + multitenancy_enabled: true, + private_tenant_enabled: false, + default_tenant: '', + }; + }); const config = { multitenancy: { enabled: true, @@ -369,13 +428,13 @@ describe('Account menu -tenant switch panel', () => { config={config as any} /> ); - process.nextTick(() => { - expect(component).toMatchSnapshot(); - done(); - }); + expect(component).toMatchSnapshot(); }); - it('renders when user has read only role', (done) => { + it('renders when user has read only role', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); useState.mockImplementationOnce(() => [['readonly'], setState]); useState.mockImplementationOnce(() => ['', setState]); const config = { @@ -398,13 +457,13 @@ describe('Account menu -tenant switch panel', () => { config={config as any} /> ); - process.nextTick(() => { - expect(component).toMatchSnapshot(); - done(); - }); + expect(component).toMatchSnapshot(); }); - it('renders when user has default read only role', (done) => { + it('renders when user has default read only role', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); useState.mockImplementationOnce(() => [['kibana_read_only'], setState]); useState.mockImplementationOnce(() => ['', setState]); const config = { @@ -427,10 +486,7 @@ describe('Account menu -tenant switch panel', () => { config={config as any} /> ); - process.nextTick(() => { - expect(component).toMatchSnapshot(); - done(); - }); + expect(component).toMatchSnapshot(); }); }); }); diff --git a/public/apps/configuration/app-router.tsx b/public/apps/configuration/app-router.tsx index 5e052a1d5..6cb97ec0e 100644 --- a/public/apps/configuration/app-router.tsx +++ b/public/apps/configuration/app-router.tsx @@ -62,6 +62,14 @@ const ROUTE_MAP: { [key: string]: RouteItem } = { name: 'Tenants', href: buildUrl(ResourceType.tenants), }, + [ResourceType.tenantsManageTab]: { + name: 'TenantsManageTab', + href: buildUrl(ResourceType.tenantsManageTab), + }, + [ResourceType.tenantsConfigureTab]: { + name: '', + href: buildUrl(ResourceType.tenantsConfigureTab), + }, [ResourceType.auth]: { name: 'Authentication', href: buildUrl(ResourceType.auth), @@ -80,6 +88,7 @@ const ROUTE_LIST = [ ROUTE_MAP[ResourceType.permissions], ROUTE_MAP[ResourceType.tenants], ROUTE_MAP[ResourceType.auditLogging], + ROUTE_MAP[ResourceType.tenantsConfigureTab], ]; const allNavPanelUrls = ROUTE_LIST.map((route) => route.href).concat([ @@ -232,7 +241,14 @@ export function AppRouter(props: AppDependencies) { path={ROUTE_MAP.tenants.href} render={() => { setGlobalBreadcrumbs(ResourceType.tenants); - return ; + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.tenants); + return ; }} /> + + + + + +

Optional: Multi-tenancy

+
+ +

+ By default tenancy is activated in Dashboards. Tenants in OpenSearch Dashboards are + spaces for saving index patterns, visualizations, dashboards, and other OpenSearch + Dashboards objects. +

+ + + { + window.location.href = buildHashUrl(ResourceType.tenants); + }} + > + Manage Multi-tenancy + + + + { + window.location.href = buildHashUrl(ResourceType.tenantsConfigureTab); + }} + > + Configure Multi-tenancy + + + +
+
); diff --git a/public/apps/configuration/panels/role-view/tenants-panel.tsx b/public/apps/configuration/panels/role-view/tenants-panel.tsx index 815cd175b..667b34f45 100644 --- a/public/apps/configuration/panels/role-view/tenants-panel.tsx +++ b/public/apps/configuration/panels/role-view/tenants-panel.tsx @@ -67,7 +67,7 @@ export function TenantsPanel(props: RoleViewTenantsPanelProps) { const fetchData = async () => { try { const rawTenantData = await fetchTenants(props.coreStart.http); - const processedTenantData = transformTenantData(rawTenantData, false); + const processedTenantData = transformTenantData(rawTenantData); setTenantPermissionDetail( transformRoleTenantPermissionData(props.tenantPermissions, processedTenantData) ); @@ -192,7 +192,7 @@ export function TenantsPanel(props: RoleViewTenantsPanelProps) { <> ({ + multitenancy_enabled: isMultiTenancyEnabled, + private_tenant_enabled: isPrivateTenantEnabled, + default_tenant: dashboardsDefaultTenant, + }); + + const [updatedConfiguration, setUpdatedConfiguration] = React.useState({ + multitenancy_enabled: isMultiTenancyEnabled, + private_tenant_enabled: isPrivateTenantEnabled, + default_tenant: dashboardsDefaultTenant, + }); + + const [showErrorWarning, setShowErrorWarning] = React.useState(false); + + const [changeInMultiTenancyOption, setChangeInMultiTenancyOption] = useState(0); + const [changeInPrivateTenantOption, setChangeInPrivateTenantOption] = useState(0); + const [changeInDefaultTenantOption, setChangeInDefaultTenantOption] = useState(0); + + const [toasts, addToast, removeToast] = useToastState(); + const [selectedComboBoxOptions, setSelectedComboBoxOptions] = useState(); + + const discardChangesFunction = async () => { + await setUpdatedConfiguration(originalConfiguration); + setSelectedComboBoxOptions(); + await setChangeInMultiTenancyOption(0); + await setChangeInPrivateTenantOption(0); + await setChangeInDefaultTenantOption(0); + }; + + const [saveChangesModal, setSaveChangesModal] = useState(null); + + const showSaveChangesModal = ( + originalConfigurationPassed: TenancyConfigSettings, + updatedConfigurationPassed: TenancyConfigSettings + ) => { + setSaveChangesModal( + setSaveChangesModal(null)} + handleSave={async (updatedConfiguration1: TenancyConfigSettings) => { + try { + console.log('Calling API'); + await updateTenancyConfiguration(props.coreStart.http, updatedConfiguration1); + setSaveChangesModal(null); + setChangeInMultiTenancyOption(0); + setChangeInPrivateTenantOption(0); + setChangeInDefaultTenantOption(0); + setOriginalConfiguration(updatedConfiguration1); + setSelectedComboBoxOptions(); + addToast( + createTenancySuccessToast( + 'savePassed', + 'Tenancy changes applied', + 'Tenancy changes applied.' + ) + ); + } catch (e) { + console.log(e); + setSaveChangesModal(null); + setChangeInMultiTenancyOption(0); + setChangeInPrivateTenantOption(0); + setChangeInDefaultTenantOption(0); + setSelectedComboBoxOptions(); + setUpdatedConfiguration(originalConfigurationPassed); + addToast(createTenancyErrorToast('saveFailed', 'Changes not applied', e.message)); + } + setSaveChangesModal(null); + }} + /> + ); + }; + + let bottomBar; + if (changeInMultiTenancyOption + changeInPrivateTenantOption + changeInDefaultTenantOption > 0) { + bottomBar = ( + + + + + {/* */} + {/* */} + + + {changeInMultiTenancyOption + + changeInPrivateTenantOption + + changeInDefaultTenantOption}{' '} + Unsaved change(s) + + + + + + + + discardChangesFunction()}> + Discard changes + + + + showSaveChangesModal(originalConfiguration, updatedConfiguration)} + > + Save Changes + + + + + + + ); + } + const [tenantData, setTenantData] = React.useState([]); + + React.useEffect(() => { + const fetchData = async () => { + try { + 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, + }); + + 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, + }); + + const rawTenantData = await fetchTenants(props.coreStart.http); + const processedTenantData = transformTenantData(rawTenantData); + setTenantData(processedTenantData); + } catch (e) { + // TODO: switch to better error display. + console.error(e); + } + }; + fetchData(); + }, [props.coreStart.http, props.tenant]); + + const onSwitchChangeTenancyEnabled = async () => { + try { + await setUpdatedConfiguration({ + multitenancy_enabled: !updatedConfiguration.multitenancy_enabled, + private_tenant_enabled: updatedConfiguration.private_tenant_enabled, + default_tenant: updatedConfiguration.default_tenant, + }); + + if ( + originalConfiguration.multitenancy_enabled === updatedConfiguration.multitenancy_enabled + ) { + await setChangeInMultiTenancyOption(1); + } else { + await setChangeInMultiTenancyOption(0); + } + } catch (e) { + console.error(e); + } + }; + + const onSwitchChangePrivateTenantEnabled = async () => { + try { + await setUpdatedConfiguration({ + multitenancy_enabled: updatedConfiguration.multitenancy_enabled, + private_tenant_enabled: !updatedConfiguration.private_tenant_enabled, + default_tenant: updatedConfiguration.default_tenant, + }); + + if ( + originalConfiguration.private_tenant_enabled === updatedConfiguration.private_tenant_enabled + ) { + await setChangeInPrivateTenantOption(1); + } else { + await setChangeInPrivateTenantOption(0); + } + if ( + updatedConfiguration.default_tenant === 'Private' && + updatedConfiguration.private_tenant_enabled + ) { + await setShowErrorWarning(true); + } else { + await setShowErrorWarning(false); + } + } catch (e) { + console.error(e); + } + }; + + const updateDefaultTenant = async (newDefaultTenant: string) => { + try { + await setUpdatedConfiguration({ + multitenancy_enabled: updatedConfiguration.multitenancy_enabled, + private_tenant_enabled: updatedConfiguration.private_tenant_enabled, + default_tenant: newDefaultTenant, + }); + if (originalConfiguration.default_tenant === updatedConfiguration.default_tenant) { + await setChangeInDefaultTenantOption(1); + } else { + await setChangeInDefaultTenantOption(0); + } + if ( + updatedConfiguration.default_tenant === 'Private' && + !updatedConfiguration.private_tenant_enabled + ) { + await setShowErrorWarning(true); + } else { + await setShowErrorWarning(false); + } + } catch (e) { + console.error(e); + } + }; + + const comboBoxOptions = []; + + for (const tenant in tenantData) { + if (tenantData[tenant].tenant) { + comboBoxOptions.push({ + label: tenantData[tenant].tenant, + }); + } + } + + const onChangeComboBoxOptions = (selectedComboBoxOptionsAfterChange) => { + setSelectedComboBoxOptions(selectedComboBoxOptionsAfterChange); + if (selectedComboBoxOptionsAfterChange.length > 0) { + updateDefaultTenant(selectedComboBoxOptionsAfterChange[0].label); + } + }; + + const errorCallOut = ( + +

+ The private tenant is disabled. Select another default + tenant. +

+
+ ); + const errorMessage = ( + + The private tenant is disabled. Select another default tenant. + + ); + return ( + <> + + +

+ Making changes to these configurations may remove users’ access to objects and other work + in their tenants and alter the Dashboards user interface accordingly. Keep this in mind + before applying changes to configurations. +

+
+ + {showErrorWarning && errorCallOut} + {!showErrorWarning && bottomBar} + + + + + + +

Multi-tenancy

+
+ + + Tenancy} + description={ + + {' '} + Selecting multi-tenancy allows you to create tenants and save OpenSearch + Dashboards objects, such as index patterns and visualizations. Tenants are useful + for safely sharing your work with other Dashboards users. + + } + className="described-form-group1" + > + onSwitchChangeTenancyEnabled()} + /> + +
+
+
+ + + + + + +

Tenants

+
+ + Global Tenant} + description={ + + {' '} + Global tenant is shared amaong all Dashboards users and cannot be disabled.{' '} + + } + className="described-form-group2" + > + +

+ Global Tenant: Enabled +

+
+
+ Private Tenant} + description={ + + Private tenant is exclusive to each user and keeps a user's personal objects + private. When using the private tenant, it does not allow access to objects + created by the user's global tenant. + + } + className="described-form-group3" + > + onSwitchChangePrivateTenantEnabled()} + disabled={!updatedConfiguration.multitenancy_enabled} + /> + +
+
+
+ {saveChangesModal} + + + + + +

Default tenant

+
+ + Default Tenant} + description={ + + {' '} + This option allows you to select the default tenant when logging into + Dashboards/Kibana for the first time. You can choose from any of the available + tenants.{' '} + + } + className="described-form-group4" + > + + + + + {showErrorWarning && errorMessage} + + + +
+
+ +
+ + ); +} diff --git a/public/apps/configuration/panels/tenant-list/manage_tab.tsx b/public/apps/configuration/panels/tenant-list/manage_tab.tsx new file mode 100644 index 000000000..8dc956abf --- /dev/null +++ b/public/apps/configuration/panels/tenant-list/manage_tab.tsx @@ -0,0 +1,553 @@ +/* + * 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 { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiLink, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiText, + EuiTitle, + EuiGlobalToastList, + Query, + EuiFacetButton, + EuiIcon, + EuiConfirmModal, + EuiCallOut, +} from '@elastic/eui'; +import React, { ReactNode, useState, useCallback } from 'react'; +import { difference } from 'lodash'; +import { HashRouter as Router, Route } from 'react-router-dom'; +import { flow } from 'lodash'; +import { TenancyConfigSettings } from '../tenancy-config/types'; +import { getCurrentUser } from '../../../../utils/auth-info-utils'; +import { AppDependencies } from '../../../types'; +import { Action, ResourceType, Tenant } from '../../types'; +import { ExternalLink, renderCustomization, tableItemsUIProps } from '../../utils/display-utils'; +import { + fetchTenants, + transformTenantData, + fetchCurrentTenant, + resolveTenantName, + updateTenant, + requestDeleteTenant, + selectTenant, + updateTenancyConfiguration, +} from '../../utils/tenant-utils'; +import { getNavLinkById } from '../../../../services/chrome_wrapper'; +import { TenantEditModal } from './edit-modal'; +import { + useToastState, + createUnknownErrorToast, + getSuccessToastMessage, +} from '../../utils/toast-utils'; +import { PageId } from '../../types'; +import { useDeleteConfirmState } from '../../utils/delete-confirm-modal-utils'; +import { showTableStatusMessage } from '../../utils/loading-spinner-utils'; +import { useContextMenuState } from '../../utils/context-menu'; +import { generateResourceName } from '../../utils/resource-utils'; +import { DocLinks } from '../../constants'; +import { TenantInstructionView } from './tenant-instruction-view'; +import { TenantList } from './tenant-list'; +import { getBreadcrumbs, Route_MAP } from '../../app-router'; +import { buildUrl } from '../../utils/url-builder'; +import { CrossPageToast } from '../../cross-page-toast'; +import { getDashboardsInfo } from '../../../../utils/dashboards-info-utils'; + +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 [selection, setSelection] = React.useState([]); + const [currentTenant, setCurrentTenant] = useState(''); + const [currentUsername, setCurrentUsername] = useState(''); + // Modal state + const [editModal, setEditModal] = useState(null); + const [toasts, addToast, removeToast] = useToastState(); + const [loading, setLoading] = useState(false); + + const [query, setQuery] = useState(null); + + const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(false); + const [isPrivateTenantEnabled, setIsPrivateTenantEnabled] = useState(false); + const [dashboardsDefaultTenant, setDashboardsDefaultTenant] = useState(''); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + const rawTenantData = await fetchTenants(props.coreStart.http); + const processedTenantData = transformTenantData(rawTenantData); + const activeTenant = await fetchCurrentTenant(props.coreStart.http); + const currentUser = await getCurrentUser(props.coreStart.http); + setCurrentUsername(currentUser); + setCurrentTenant(resolveTenantName(activeTenant, currentUser)); + setTenantData(processedTenantData); + setIsMultiTenancyEnabled( + (await getDashboardsInfo(props.coreStart.http)).multitenancy_enabled + ); + setIsPrivateTenantEnabled( + (await getDashboardsInfo(props.coreStart.http)).private_tenant_enabled + ); + setDashboardsDefaultTenant((await getDashboardsInfo(props.coreStart.http)).default_tenant); + } catch (e) { + console.log(e); + setErrorFlag(true); + } finally { + setLoading(false); + } + }, [props.coreStart.http]); + + React.useEffect(() => { + fetchData(); + }, [props.coreStart.http, fetchData]); + + const handleDelete = async () => { + const tenantsToDelete: string[] = selection.map((r) => r.tenant); + try { + await requestDeleteTenant(props.coreStart.http, tenantsToDelete); + setTenantData(difference(tenantData, selection)); + setSelection([]); + } catch (e) { + console.log(e); + } finally { + closeActionsMenu(); + } + }; + const [showDeleteConfirmModal, deleteConfirmModal] = useDeleteConfirmState( + handleDelete, + 'tenant(s)' + ); + + const changeTenant = async (tenantValue: string) => { + const selectedTenant = await selectTenant(props.coreStart.http, { + tenant: tenantValue, + username: currentUsername, + }); + setCurrentTenant(resolveTenantName(selectedTenant, currentUsername)); + }; + + const getTenantName = (tenantValue: string) => { + return tenantData.find((tenant: Tenant) => tenant.tenantValue === tenantValue)?.tenant; + }; + + const switchToSelectedTenant = async (tenantValue: string, tenantName: string) => { + try { + await changeTenant(tenantValue); + // refresh the page to let the page to reload app configs, like dark mode etc. + // also refresh the tenant to ensure tenant is set correctly when sharing urls. + window.location.reload(); + } catch (e) { + console.log(e); + addToast(createUnknownErrorToast('selectFailed', `select ${tenantName} tenant`)); + } finally { + closeActionsMenu(); + } + }; + + const [isModalVisible, setIsModalVisible] = useState(false); + + const closeModal = () => setIsModalVisible(false); + const showModal = () => setIsModalVisible(true); + + const setSelectedTenantAsGlobalDefaultAPICall = async (tenantName: string) => { + try { + await updateTenancyConfiguration(props.coreStart.http, { + multitenancy_enabled: isMultiTenancyEnabled, + private_tenant_enabled: isPrivateTenantEnabled, + default_tenant: tenantName, + }); + window.location.reload(); + } catch (e) { + console.log(e); + addToast(createUnknownErrorToast('selectFailed', `select ${tenantName} tenant`)); + } finally { + closeModal(); + closeActionsMenu(); + } + }; + + let defaultTenantModal; + if (isModalVisible && isMultiTenancyEnabled) { + defaultTenantModal = ( + setSelectedTenantAsGlobalDefaultAPICall(selection[0].tenant)} + cancelButtonText="Discard Changes" + confirmButtonText="Change Default Tenant" + defaultFocusedButton="confirm" + > +

+ Users will load into {selection[0].tenant} tenant when they log into Dashboards if they + have the appropriate permissions. If users don’t have permissions to a custom tenant they + will load into the global tenant. +

+
+ ); + } + + const renderConfigurePage = async () => { + return ( + + { + setGlobalBreadcrumbs(ResourceType.tenants); + return ; + }} + /> + + + ); + }; + + let tenancyDisabledWarning; + if (!true) { + tenancyDisabledWarning = ( + +

+ Tenancy is currently disabled and users don't have access to this feature. To create, + edit tenants you must enabled tenanc throught he configure tenancy page. +

+ renderConfigurePage().then()} + > + Configure tenancy + +
+ ); + } + + const viewOrCreateDashboard = async (tenantValue: string, action: string) => { + try { + await changeTenant(tenantValue); + window.location.href = getNavLinkById(props.coreStart, PageId.dashboardId); + } catch (e) { + console.log(e); + addToast( + createUnknownErrorToast( + `${action}Dashboard`, + `${action} dashboard for ${getTenantName(tenantValue)} tenant` + ) + ); + } + }; + + const viewOrCreateVisualization = async (tenantValue: string, action: string) => { + try { + await changeTenant(tenantValue); + window.location.href = getNavLinkById(props.coreStart, PageId.visualizationId); + } catch (e) { + console.log(e); + addToast( + createUnknownErrorToast( + `${action}Visualization`, + `${action} visualization for ${getTenantName(tenantValue)} tenant` + ) + ); + } + }; + + function loadTenantStatus(tenantName: string) { + if (tenantName === 'Global') { + if ( + !isMultiTenancyEnabled || + tenantName === dashboardsDefaultTenant || + (dashboardsDefaultTenant === 'Private' && !isPrivateTenantEnabled) || + dashboardsDefaultTenant === '' + ) { + return ( + }> + Default Tenant + + ); + } + return }>Enabled; + } + + if (tenantName === 'Private') { + if (isPrivateTenantEnabled && isMultiTenancyEnabled) { + if (tenantName === dashboardsDefaultTenant) { + return ( + }> + Default Tenant + + ); + } + + return ( + }>Enabled + ); + } + return ( + }>Disabled + ); + } + + if (isMultiTenancyEnabled) { + if (tenantName === dashboardsDefaultTenant) { + return ( + }> + Default Tenant + + ); + } + return }>Enabled; + } + + return }>Disabled; + } + + const columns = [ + { + field: 'tenant', + name: 'Name', + render: (tenantName: string) => ( + <> + {tenantName} + {tenantName === currentTenant && ( + <> +   + Current + + )} + + ), + sortable: true, + }, + { + field: 'tenant', + name: 'Status', + render: (tenantName: string) => <>{loadTenantStatus(tenantName)}, + }, + { + field: 'description', + name: 'Description', + truncateText: true, + }, + { + field: 'tenantValue', + name: 'Dashboard', + render: (tenant: string) => ( + <> + viewOrCreateDashboard(tenant, Action.view)}> + View dashboard + + + ), + }, + { + field: 'tenantValue', + name: 'Visualizations', + render: (tenant: string) => ( + <> + viewOrCreateVisualization(tenant, Action.view)}> + View visualizations + + + ), + }, + { + field: 'reserved', + name: 'Customization', + render: (reserved: boolean) => { + return renderCustomization(reserved, tableItemsUIProps); + }, + }, + ]; + + const actionsMenuItems = [ + switchToSelectedTenant(selection[0].tenantValue, selection[0].tenant)} + > + Switch to selected tenant + , + showEditModal(selection[0].tenant, Action.edit, selection[0].description)} + > + Edit + , + + showEditModal( + generateResourceName(Action.duplicate, selection[0].tenant), + Action.duplicate, + selection[0].description + ) + } + > + Duplicate + , + viewOrCreateDashboard(selection[0].tenantValue, Action.create)} + > + Create dashboard + , + viewOrCreateVisualization(selection[0].tenantValue, Action.create)} + > + Create visualizations + , + + Set as Default Tenant + , + tenant.reserved)} + > + Delete + , + ]; + + const [actionsMenu, closeActionsMenu] = useContextMenuState('Actions', {}, actionsMenuItems); + + const showEditModal = ( + initialTenantName: string, + action: Action, + initialTenantDescription: string + ) => { + setEditModal( + setEditModal(null)} + handleSave={async (tenantName: string, tenantDescription: string) => { + try { + await updateTenant(props.coreStart.http, tenantName, { + description: tenantDescription, + }); + setEditModal(null); + fetchData(); + addToast({ + id: 'saveSucceeded', + title: getSuccessToastMessage('Tenant', action, tenantName), + color: 'success', + }); + } catch (e) { + console.log(e); + addToast(createUnknownErrorToast('saveFailed', `save ${action} tenant`)); + } + }} + /> + ); + }; + + if (!props.config.multitenancy.enabled) { + return ; + } + /* eslint-disable */ + return ( + <> + {/*{tenancyDisabledWarning}*/} + + + + + + +

+ Tenants + + {' '} + ({Query.execute(query || '', tenantData).length}) + +

+
+ + Manage tenants that already have been created. Global tenant is the default when + tenancy is turned off. + +
+ + + {actionsMenu} + + showEditModal('', Action.create, '')} + > + Create tenant + + + + +
+ {defaultTenantModal} + + { + 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, tenantData)} + /> + +
+ {editModal} + {deleteConfirmModal} + + + ); +} diff --git a/public/apps/configuration/panels/tenant-list/save_changes_modal.tsx b/public/apps/configuration/panels/tenant-list/save_changes_modal.tsx new file mode 100644 index 000000000..ba5c08fa7 --- /dev/null +++ b/public/apps/configuration/panels/tenant-list/save_changes_modal.tsx @@ -0,0 +1,189 @@ +/* + * 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 { + EuiPageContentHeader, + EuiHorizontalRule, + EuiCheckbox, + EuiConfirmModal, +} from '@elastic/eui'; +import React from 'react'; +import { ExternalLink } from '../../utils/display-utils'; +import { TenancyConfigSettings } from '../tenancy-config/types'; +import { DocLinks } from '../../constants'; + +interface SaveChangesModalDeps { + originalTenancyConfig: TenancyConfigSettings; + updatedTenancyConfig: TenancyConfigSettings; + handleClose: () => void; + handleSave: (updatedTenancyConfig: TenancyConfigSettings) => Promise; +} + +export function SaveChangesModalGenerator(props: SaveChangesModalDeps) { + let globalDefaultModal; + const [tenancyChecked, setTenancyChecked] = React.useState(false); + const [privateTenancyChecked, setPrivateTenancyChecked] = React.useState(false); + const [defaultTenantChecked, setDefaultTenantChecked] = React.useState(false); + if ( + props.originalTenancyConfig.default_tenant !== props.updatedTenancyConfig.default_tenant && + props.originalTenancyConfig.multitenancy_enabled === + props.updatedTenancyConfig.multitenancy_enabled && + props.originalTenancyConfig.private_tenant_enabled === + props.updatedTenancyConfig.private_tenant_enabled + ) { + globalDefaultModal = ( + { + await props.handleSave(props.updatedTenancyConfig); + }} + cancelButtonText="Discard Changes" + confirmButtonText="Change Default Tenant" + defaultFocusedButton="confirm" + > +

+ Users will load into {props.updatedTenancyConfig.default_tenant} tenant when they log into + Dashboards if they have the appropriate permissions. If users don’t have permissions to a + custom tenant they will load into the global tenant.{' '} + +

+
+ ); + return globalDefaultModal; + } + + let tenancyToggled = false; + let privateTenantToggled = false; + let defaultTenantChanged = false; + let tenancyChangeCheckbox; + let privateTenancyChangeCheckbox; + let defaultTenantChangeCheckbox; + let tenancyChangeMessage = 'Message 1'; + let privateTenancyChangeMessage = 'Message 2'; + let defaultTenantChangeMessage = 'Message 3'; + + if ( + props.updatedTenancyConfig.multitenancy_enabled !== + props.originalTenancyConfig.multitenancy_enabled + ) { + tenancyToggled = true; + if (props.updatedTenancyConfig.multitenancy_enabled) { + tenancyChangeMessage = + 'Enable Tenancy - Users will be able to make use of the Tenancy Feature ' + + 'in OpenSearch Dashboards and switch between tenants they have access to.'; + } else { + tenancyChangeMessage = + "Disabling Tenancy - Users won't be able to access index patterns, visualizations, " + + 'dashboards, and other OpenSearch Dashboards objects saved in tenants other than the ' + + 'global tenant.'; + } + tenancyChangeCheckbox = ( + setTenancyChecked(!tenancyChecked)} + /> + ); + } + + if ( + props.updatedTenancyConfig.private_tenant_enabled !== + props.originalTenancyConfig.private_tenant_enabled && + props.updatedTenancyConfig.multitenancy_enabled + ) { + privateTenantToggled = true; + if (props.updatedTenancyConfig.private_tenant_enabled) { + privateTenancyChangeMessage = + 'Enable private tenant - Users will be able to create index patterns, visualizations, and ' + + 'other OpenSearch Dashboards objects in a private tenant that they only have access to.'; + } else { + privateTenancyChangeMessage = + "Disabling private tenant - Users won't be able to access index patterns, visualizations, and " + + 'other OpenSearch Dashboards saved in their private tenant.'; + } + privateTenancyChangeCheckbox = ( + setPrivateTenancyChecked(!privateTenancyChecked)} + /> + ); + } + + if ( + props.updatedTenancyConfig.default_tenant !== props.originalTenancyConfig.default_tenant && + props.updatedTenancyConfig.multitenancy_enabled + ) { + defaultTenantChanged = true; + defaultTenantChangeMessage = + 'Users will load into ' + + props.updatedTenancyConfig.default_tenant + + ' tenant when they log into Dashboards ' + + 'if they have the appropriate permissions. If users don’t have permissions to a custom ' + + 'tenant they will load into the global tenant.'; + defaultTenantChangeCheckbox = ( + setDefaultTenantChecked(!defaultTenantChecked)} + /> + ); + } + + globalDefaultModal = ( + { + await props.handleSave(props.updatedTenancyConfig); + }} + cancelButtonText="Cancel" + confirmButtonText="Apply changes" + defaultFocusedButton="confirm" + buttonColor={'danger'} + confirmButtonDisabled={ + !( + tenancyToggled === tenancyChecked && + privateTenantToggled === privateTenancyChecked && + defaultTenantChanged === defaultTenantChecked + ) + } + > +

+ The changes you are about to make can break large portions of OpenSearch Dashboards. You + might be able to revert some of these changes.{' '} + +

+ + + + +

Review changes and check the boxes below:

+
+ {tenancyChangeCheckbox} + {privateTenancyChangeCheckbox} + {defaultTenantChangeCheckbox} +
+ ); + + return globalDefaultModal; +} diff --git a/public/apps/configuration/panels/tenant-list/tenant-list.tsx b/public/apps/configuration/panels/tenant-list/tenant-list.tsx index 84d17ccea..0d12162c1 100644 --- a/public/apps/configuration/panels/tenant-list/tenant-list.tsx +++ b/public/apps/configuration/panels/tenant-list/tenant-list.tsx @@ -14,383 +14,137 @@ */ import { - EuiBadge, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiInMemoryTable, - EuiLink, - EuiPageBody, - EuiPageContent, - EuiPageContentHeader, - EuiPageContentHeaderSection, EuiPageHeader, EuiText, EuiTitle, - EuiGlobalToastList, - Query, + EuiTabs, + EuiTab, + EuiCallOut, + EuiButton, } from '@elastic/eui'; -import React, { ReactNode, useState, useCallback } from 'react'; -import { difference } from 'lodash'; -import { getCurrentUser } from '../../../../utils/auth-info-utils'; +import { Route } from 'react-router-dom'; +import React, { useState, useMemo, useCallback } from 'react'; +import { ManageTab } from './manage_tab'; +import { ConfigureTab1 } from './configure_tab1'; import { AppDependencies } from '../../../types'; -import { Action, Tenant } from '../../types'; -import { ExternalLink, renderCustomization, tableItemsUIProps } from '../../utils/display-utils'; -import { - fetchTenants, - transformTenantData, - fetchCurrentTenant, - resolveTenantName, - updateTenant, - requestDeleteTenant, - selectTenant, -} from '../../utils/tenant-utils'; -import { getNavLinkById } from '../../../../services/chrome_wrapper'; -import { TenantEditModal } from './edit-modal'; -import { - useToastState, - createUnknownErrorToast, - getSuccessToastMessage, -} from '../../utils/toast-utils'; -import { PageId } from '../../types'; -import { useDeleteConfirmState } from '../../utils/delete-confirm-modal-utils'; -import { showTableStatusMessage } from '../../utils/loading-spinner-utils'; -import { useContextMenuState } from '../../utils/context-menu'; -import { generateResourceName } from '../../utils/resource-utils'; +import { ExternalLink } from '../../utils/display-utils'; +import { displayBoolean } from '../../utils/display-utils'; import { DocLinks } from '../../constants'; -import { TenantInstructionView } from './tenant-instruction-view'; +import { getDashboardsInfo } from '../../../../utils/dashboards-info-utils'; -export function TenantList(props: AppDependencies) { - const [tenantData, setTenantData] = React.useState([]); - const [errorFlag, setErrorFlag] = React.useState(false); - const [selection, setSelection] = React.useState([]); - const [currentTenant, setCurrentTenant] = useState(''); - const [currentUsername, setCurrentUsername] = useState(''); - // Modal state - const [editModal, setEditModal] = useState(null); - const [toasts, addToast, removeToast] = useToastState(); - const [loading, setLoading] = useState(false); - - const [query, setQuery] = useState(null); - - // Configuration - const isPrivateEnabled = props.config.multitenancy.tenants.enable_private; +interface TenantListProps extends AppDependencies { + tabID: string; +} - const fetchData = useCallback(async () => { - try { - setLoading(true); - const rawTenantData = await fetchTenants(props.coreStart.http); - const processedTenantData = transformTenantData(rawTenantData, isPrivateEnabled); - const activeTenant = await fetchCurrentTenant(props.coreStart.http); - const currentUser = await getCurrentUser(props.coreStart.http); - setCurrentUsername(currentUser); - setCurrentTenant(resolveTenantName(activeTenant, currentUser)); - setTenantData(processedTenantData); - } catch (e) { - console.log(e); - setErrorFlag(true); - } finally { - setLoading(false); - } - }, [isPrivateEnabled, props.coreStart.http]); +export function TenantList(props: TenantListProps) { + const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(false); + const [selectedTabId, setSelectedTabId] = useState(props.tabID); React.useEffect(() => { + const fetchData = async () => { + try { + await setIsMultiTenancyEnabled( + (await getDashboardsInfo(props.coreStart.http)).multitenancy_enabled + ); + } catch (e) { + console.log(e); + } + }; fetchData(); - }, [props.coreStart.http, fetchData]); + }, [props.coreStart.http, selectedTabId]); - const handleDelete = async () => { - const tenantsToDelete: string[] = selection.map((r) => r.tenant); - try { - await requestDeleteTenant(props.coreStart.http, tenantsToDelete); - setTenantData(difference(tenantData, selection)); - setSelection([]); - } catch (e) { - console.log(e); - } finally { - closeActionsMenu(); - } - }; - const [showDeleteConfirmModal, deleteConfirmModal] = useDeleteConfirmState( - handleDelete, - 'tenant(s)' + const tenancyDisabledWarning = ( + <> + +

+ Tenancy is currently disabled and users don't have access to this feature. To create, + edit tenants you must enabled tenanc throught he configure tenancy page. +

+ onSelectedTabChanged('Configure')} + > + Configure tenancy + +
+ ); - const changeTenant = async (tenantValue: string) => { - const selectedTenant = await selectTenant(props.coreStart.http, { - tenant: tenantValue, - username: currentUsername, - }); - setCurrentTenant(resolveTenantName(selectedTenant, currentUsername)); - }; - - const getTenantName = (tenantValue: string) => { - return tenantData.find((tenant: Tenant) => tenant.tenantValue === tenantValue)?.tenant; - }; - - const switchToSelectedTenant = async (tenantValue: string, tenantName: string) => { - try { - await changeTenant(tenantValue); - // refresh the page to let the page to reload app configs, like dark mode etc. - // also refresh the tenant to ensure tenant is set correctly when sharing urls. - window.location.reload(); - } catch (e) { - console.log(e); - addToast(createUnknownErrorToast('selectFailed', `select ${tenantName} tenant`)); - } finally { - closeActionsMenu(); - } - }; - - const viewOrCreateDashboard = async (tenantValue: string, action: string) => { - try { - await changeTenant(tenantValue); - window.location.href = getNavLinkById(props.coreStart, PageId.dashboardId); - } catch (e) { - console.log(e); - addToast( - createUnknownErrorToast( - `${action}Dashboard`, - `${action} dashboard for ${getTenantName(tenantValue)} tenant` - ) - ); - } - }; - - const viewOrCreateVisualization = async (tenantValue: string, action: string) => { - try { - await changeTenant(tenantValue); - window.location.href = getNavLinkById(props.coreStart, PageId.visualizationId); - } catch (e) { - console.log(e); - addToast( - createUnknownErrorToast( - `${action}Visualization`, - `${action} visualization for ${getTenantName(tenantValue)} tenant` - ) - ); - } - }; - - const columns = [ - { - field: 'tenant', - name: 'Name', - render: (tenantName: string) => ( - <> - {tenantName} - {tenantName === currentTenant && ( - <> -   - Current - - )} - - ), - sortable: true, - }, - { - field: 'description', - name: 'Description', - truncateText: true, - }, - { - field: 'tenantValue', - name: 'Dashboard', - render: (tenant: string) => ( - <> - viewOrCreateDashboard(tenant, Action.view)}> - View dashboard - - - ), - }, - { - field: 'tenantValue', - name: 'Visualizations', - render: (tenant: string) => ( - <> - viewOrCreateVisualization(tenant, Action.view)}> - View visualizations - - - ), - }, - { - field: 'reserved', - name: 'Customization', - render: (reserved: boolean) => { - return renderCustomization(reserved, tableItemsUIProps); + const tabs = useMemo( + () => [ + { + id: 'Manage', + name: 'Manage', + content: ( + { + return ( + <> + + + ); + }} + /> + ), }, - }, - ]; + { + id: 'Configure', + name: 'Configure', + content: ( + { + return ; + }} + /> + ), + }, + ], + [props] + ); - const actionsMenuItems = [ - switchToSelectedTenant(selection[0].tenantValue, selection[0].tenant)} - > - Switch to selected tenant - , - showEditModal(selection[0].tenant, Action.edit, selection[0].description)} - > - Edit - , - - showEditModal( - generateResourceName(Action.duplicate, selection[0].tenant), - Action.duplicate, - selection[0].description - ) - } - > - Duplicate - , - viewOrCreateDashboard(selection[0].tenantValue, Action.create)} - > - Create dashboard - , - viewOrCreateVisualization(selection[0].tenantValue, Action.create)} - > - Create visualizations - , - tenant.reserved)} - > - Delete - , - ]; + const selectedTabContent = useMemo(() => { + return tabs.find((obj) => obj.id === selectedTabId)?.content; + }, [selectedTabId, tabs]); - const [actionsMenu, closeActionsMenu] = useContextMenuState('Actions', {}, actionsMenuItems); + const onSelectedTabChanged = (id: string) => { + setSelectedTabId(id); + }; - const showEditModal = ( - initialTenantName: string, - action: Action, - initialTenantDescription: string - ) => { - setEditModal( - setEditModal(null)} - handleSave={async (tenantName: string, tenantDescription: string) => { - try { - await updateTenant(props.coreStart.http, tenantName, { - description: tenantDescription, - }); - setEditModal(null); - fetchData(); - addToast({ - id: 'saveSucceeded', - title: getSuccessToastMessage('Tenant', action, tenantName), - color: 'success', - }); - } catch (e) { - console.log(e); - addToast(createUnknownErrorToast('saveFailed', `save ${action} tenant`)); - } - }} - /> - ); + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + prepend={tab.prepend} + append={tab.append} + > + {tab.name} + + )); }; - if (!props.config.multitenancy.enabled) { - return ; - } - /* eslint-disable */ return ( <> -

Tenants

+

Multi-tenancy

- - - - -

- Tenants - - {' '} - ({Query.execute(query || '', tenantData).length}) - -

-
- - Tenants in OpenSearch Dashboards are spaces for saving index patterns, visualizations, - dashboards, and other OpenSearch Dashboards objects. Use tenants to safely share your - work with other OpenSearch Dashboards users. You can control which roles have access - to a tenant and whether those roles have read or write access. The “Current” label - indicates which tenant you are using now. Switch to another tenant anytime from your - user profile, which is located on the top right of the screen. - -
- - - {actionsMenu} - - showEditModal('', Action.create, '')} - > - Create tenant - - - - -
- - { - 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, tenantData)} - /> - -
- {editModal} - {deleteConfirmModal} - + + Tenants in OpenSearch Dashboards are spaces for saving index patterns, visualizations, + dashboards, and other OpenSearch Dashboards objects. Tenants are useful for safely sharing + your work with other OpenSearch Dashboards users. You can control which roles have access to + a tenant and whether those roles have read or write access.{' '} + + + + {renderTabs()} + {!isMultiTenancyEnabled && selectedTabId === 'Manage' && tenancyDisabledWarning} + {selectedTabContent} ); } 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 c43eeda0b..64c1e1b64 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 @@ -2,15 +2,7 @@ exports[`Tenant list Action menu click Duplicate click 1`] = ` - - -

- Tenants -

-
-
+ @@ -31,12 +23,9 @@ exports[`Tenant list Action menu click Duplicate click 1`] = ` - Tenants in OpenSearch Dashboards are spaces for saving index patterns, visualizations, dashboards, and other OpenSearch Dashboards objects. Use tenants to safely share your work with other OpenSearch Dashboards users. You can control which roles have access to a tenant and whether those roles have read or write access. The “Current” label indicates which tenant you are using now. Switch to another tenant anytime from your user profile, which is located on the top right of the screen. - + Manage tenants that already have been created. Global tenant is the default when tenancy is turned off. @@ -82,6 +71,14 @@ exports[`Tenant list Action menu click Duplicate click 1`] = ` > Create visualizations + + Set as Default Tenant + - - -

- Tenants -

-
-
+ @@ -217,12 +211,9 @@ exports[`Tenant list Action menu click Edit click 1`] = ` - Tenants in OpenSearch Dashboards are spaces for saving index patterns, visualizations, dashboards, and other OpenSearch Dashboards objects. Use tenants to safely share your work with other OpenSearch Dashboards users. You can control which roles have access to a tenant and whether those roles have read or write access. The “Current” label indicates which tenant you are using now. Switch to another tenant anytime from your user profile, which is located on the top right of the screen. - + Manage tenants that already have been created. Global tenant is the default when tenancy is turned off. @@ -268,6 +259,14 @@ exports[`Tenant list Action menu click Edit click 1`] = ` > Create visualizations
+ + Set as Default Tenant + ({ getSuccessToastMessage: jest.fn(), createUnknownErrorToast: jest.fn(), })); + +jest.mock('../../../../../utils/dashboards-info-utils', () => ({ + getDashboardsInfo: jest.fn().mockImplementation(() => { + return mockDashboardsInfo; + }), +})); + +const mockDashboardsInfo = { + multitenancy_enabled: true, + private_tenant_enabled: true, + default_tenant: '', +}; + // eslint-disable-next-line const mockTenantUtils = require('../../../utils/tenant-utils'); // eslint-disable-next-line @@ -46,6 +60,11 @@ describe('Tenant list', () => { const setState = jest.fn(); const mockCoreStart = { http: 1, + chrome: { + setBreadcrumbs() { + return 1; + }, + }, }; const config = { multitenancy: { @@ -61,6 +80,10 @@ describe('Tenant list', () => { }); it('Render empty', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return mockDashboardsInfo; + }); + const mockTenantListingData = [ { tenant: 'tenant_1', @@ -73,7 +96,7 @@ describe('Tenant list', () => { mockTenantUtils.transformTenantData = jest.fn().mockReturnValue(mockTenantListingData); const component = shallow( - { }); it('renders when multitenancy is disabled in the opensearch_dashboards.yml', () => { + (getDashboardsInfo as jest.Mock).mockImplementation(() => { + return { + multitenancy_enabled: false, + private_tenant_enabled: true, + default_tenant: '', + }; + }); const config1 = { multitenancy: { enabled: false, @@ -94,7 +124,7 @@ describe('Tenant list', () => { }, }; const component = shallow( - { }); shallow( - { mockTenantUtils.transformTenantData = jest.fn().mockReturnValue(mockTenantListingData); shallow( - { it('delete tenant', (done) => { shallow( - { const loggingFunc = jest.fn(); jest.spyOn(console, 'log').mockImplementationOnce(loggingFunc); shallow( - { it('submit tenant', () => { const component = shallow( - { throw error; }); const component = shallow( - { it('edit and delete should be disabled when selected tenant is reserved', () => { jest.spyOn(React, 'useState').mockImplementation(() => [[sampleReservedTenant], jest.fn()]); const component = shallow( - { .spyOn(React, 'useState') .mockImplementation(() => [[sampleReservedTenant, sampleCustomTenant1], jest.fn()]); const component = shallow( - { .spyOn(React, 'useState') .mockImplementation(() => [[sampleCustomTenant1, sampleCustomTenant2], jest.fn()]); const component = shallow( - { beforeEach(() => { jest.spyOn(React, 'useState').mockImplementation(() => [[sampleCustomTenant1], jest.fn()]); component = shallow( - + + + +

+ Optional: Multi-tenancy +

+
+ +

+ By default tenancy is activated in Dashboards. Tenants in OpenSearch Dashboards are spaces for saving index patterns, visualizations, dashboards, and other OpenSearch Dashboards objects. +

+ + + + Manage Multi-tenancy + + + + + Configure Multi-tenancy + + + +
+
`; @@ -478,6 +523,51 @@ exports[`Get started (landing page) renders when backend configuration is enable + + + +

+ Optional: Multi-tenancy +

+
+ +

+ By default tenancy is activated in Dashboards. Tenants in OpenSearch Dashboards are spaces for saving index patterns, visualizations, dashboards, and other OpenSearch Dashboards objects. +

+ + + + Manage Multi-tenancy + + + + + Configure Multi-tenancy + + + +
+
`; diff --git a/public/apps/configuration/types.ts b/public/apps/configuration/types.ts index 5cccfa347..06ef44369 100644 --- a/public/apps/configuration/types.ts +++ b/public/apps/configuration/types.ts @@ -24,6 +24,8 @@ export enum ResourceType { users = 'users', permissions = 'permissions', tenants = 'tenants', + tenantsManageTab = 'tenantsManageTab', + tenantsConfigureTab = 'tenantsConfigureTab', auth = 'auth', auditLogging = 'auditLogging', } @@ -33,6 +35,8 @@ export enum Action { create = 'create', edit = 'edit', duplicate = 'duplicate', + manage = 'manage', + configure = 'configure', } export enum SubAction { diff --git a/public/apps/configuration/utils/request-utils.ts b/public/apps/configuration/utils/request-utils.ts index b4fbb4246..f348f49ad 100644 --- a/public/apps/configuration/utils/request-utils.ts +++ b/public/apps/configuration/utils/request-utils.ts @@ -30,6 +30,10 @@ export async function httpPost(http: HttpStart, url: string, body?: object): return await request(http.post, url, body); } +export async function httpPut(http: HttpStart, url: string, body?: object): Promise { + return await request(http.put, url, body); +} + export async function httpDelete(http: HttpStart, url: string): Promise { return await request(http.delete, url); } diff --git a/public/apps/configuration/utils/tenancy-config_util.tsx b/public/apps/configuration/utils/tenancy-config_util.tsx new file mode 100644 index 000000000..215e44622 --- /dev/null +++ b/public/apps/configuration/utils/tenancy-config_util.tsx @@ -0,0 +1,28 @@ +/* + * 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 { HttpStart } from 'opensearch-dashboards/public'; +import { API_ENDPOINT_TENANCY_CONFIGS } from '../constants'; +import { httpGet, httpPut, httpPost } from './request-utils'; +import { TenancyConfigSettings } from '../panels/tenancy-config/types'; + +export async function updateTenancyConfig(http: HttpStart, updateObject: TenancyConfigSettings) { + return await httpPost(http, API_ENDPOINT_TENANCY_CONFIGS, updateObject); +} + +export async function getTenancyConfig(http: HttpStart): Promise { + const rawConfiguration = await httpGet(http, API_ENDPOINT_TENANCY_CONFIGS); + return rawConfiguration; +} diff --git a/public/apps/configuration/utils/tenant-utils.tsx b/public/apps/configuration/utils/tenant-utils.tsx index 7abf132f2..2aee7c355 100644 --- a/public/apps/configuration/utils/tenant-utils.tsx +++ b/public/apps/configuration/utils/tenant-utils.tsx @@ -19,6 +19,7 @@ import React from 'react'; import { i18n } from '@osd/i18n'; import { API_ENDPOINT_MULTITENANCY, + API_ENDPOINT_TENANCY_CONFIGS, API_ENDPOINT_TENANTS, RoleViewTenantInvalidText, TENANT_READ_PERMISSION, @@ -35,9 +36,10 @@ import { TenantSelect, TenantUpdate, } from '../types'; -import { httpDelete, httpGet, httpPost } from './request-utils'; +import { httpDelete, httpGet, httpPost, httpPut } from './request-utils'; import { getResourceUrl } from './resource-utils'; import { + API_ENDPOINT_DASHBOARDSINFO, DEFAULT_TENANT, GLOBAL_TENANT_RENDERING_TEXT, GLOBAL_TENANT_SYMBOL, @@ -45,7 +47,9 @@ import { isGlobalTenant, isRenderingPrivateTenant, PRIVATE_TENANT_RENDERING_TEXT, + SAML_AUTH_LOGIN, } from '../../../../common'; +import { TenancyConfigSettings } from '../panels/tenancy-config/types'; export const GLOBAL_USER_DICT: { [key: string]: string } = { Label: 'Global', @@ -67,10 +71,7 @@ export async function fetchTenantNameList(http: HttpStart): Promise { return Object.keys(await fetchTenants(http)); } -export function transformTenantData( - rawTenantData: DataObject, - isPrivateEnabled: boolean -): Tenant[] { +export function transformTenantData(rawTenantData: DataObject): Tenant[] { // @ts-ignore const tenantList: Tenant[] = map(rawTenantData, (v: Tenant, k?: string) => ({ tenant: k === globalTenantName ? GLOBAL_USER_DICT.Label : k || GLOBAL_TENANT_SYMBOL, @@ -78,15 +79,12 @@ export function transformTenantData( description: k === globalTenantName ? GLOBAL_USER_DICT.Description : v.description, tenantValue: k === globalTenantName ? GLOBAL_USER_DICT.Value : k || GLOBAL_TENANT_SYMBOL, })); - if (isPrivateEnabled) { - // Insert Private Tenant in List - tenantList.splice(1, 0, { - tenant: PRIVATE_USER_DICT.Label, - reserved: true, - description: PRIVATE_USER_DICT.Description, - tenantValue: PRIVATE_USER_DICT.Value, - }); - } + tenantList.splice(1, 0, { + tenant: PRIVATE_USER_DICT.Label, + reserved: true, + description: PRIVATE_USER_DICT.Description, + tenantValue: PRIVATE_USER_DICT.Value, + }); return tenantList; } @@ -102,6 +100,15 @@ export async function updateTenant( return await httpPost(http, getResourceUrl(API_ENDPOINT_TENANTS, tenantName), updateObject); } +export async function updateTenancyConfiguration( + http: HttpStart, + updatedTenancyConfig: TenancyConfigSettings +) { + await httpPut(http, API_ENDPOINT_TENANCY_CONFIGS, updatedTenancyConfig); + + return; +} + export async function requestDeleteTenant(http: HttpStart, tenants: string[]) { for (const tenant of tenants) { await httpDelete(http, getResourceUrl(API_ENDPOINT_TENANTS, tenant)); diff --git a/public/apps/configuration/utils/test/tenant-utils.test.tsx b/public/apps/configuration/utils/test/tenant-utils.test.tsx index 4d5c773d1..413fea90f 100644 --- a/public/apps/configuration/utils/test/tenant-utils.test.tsx +++ b/public/apps/configuration/utils/test/tenant-utils.test.tsx @@ -82,25 +82,23 @@ describe('Tenant list utils', () => { }; it('transform global tenant', () => { - const result = transformTenantData({ global_tenant: globalTenant }, false); - expect(result.length).toBe(1); + const result = transformTenantData({ global_tenant: globalTenant }); + expect(result.length).toBe(2); expect(result[0]).toEqual(expectedGlobalTenantListing); }); it('transform private tenant', () => { - const result = transformTenantData({}, true); + const result = transformTenantData({}); expect(result.length).toBe(1); expect(result[0]).toEqual(expectedPrivateTenantListing); }); it('transform global and custom tenant', () => { - const result = transformTenantData( - { global_tenant: globalTenant, dummy: sampleTenant1 }, - false - ); - expect(result.length).toBe(2); + const result = transformTenantData({ global_tenant: globalTenant, dummy: sampleTenant1 }); + expect(result.length).toBe(3); expect(result[0]).toEqual(expectedGlobalTenantListing); - expect(result[1]).toMatchObject(expectedTenantListing); + expect(result[1]).toMatchObject(expectedPrivateTenantListing); + expect(result[2]).toMatchObject(expectedTenantListing); }); it('transform global, private and custom tenant', () => { diff --git a/public/apps/configuration/utils/test/toast-utils.test.tsx b/public/apps/configuration/utils/test/toast-utils.test.tsx index 80e21c8ae..921751da5 100644 --- a/public/apps/configuration/utils/test/toast-utils.test.tsx +++ b/public/apps/configuration/utils/test/toast-utils.test.tsx @@ -89,6 +89,7 @@ describe('Toast utils', () => { const result = createUnknownErrorToast('dummy', 'dummy_action'); const expectedUnknownErrorToast: Toast = { id: 'dummy', + iconType: 'alert', color: 'danger', title: 'Failed to dummy_action', text: diff --git a/public/apps/configuration/utils/toast-utils.tsx b/public/apps/configuration/utils/toast-utils.tsx index f73e8f841..db6fea635 100644 --- a/public/apps/configuration/utils/toast-utils.tsx +++ b/public/apps/configuration/utils/toast-utils.tsx @@ -20,6 +20,17 @@ export function createErrorToast(id: string, title: string, text: string): Toast return { id, color: 'danger', + iconType: 'alert', + title, + text, + }; +} + +export function createSuccessToast(id: string, title: string, text: string): Toast { + return { + id, + color: 'success', + iconType: 'check', title, text, }; @@ -33,6 +44,14 @@ export function createUnknownErrorToast(id: string, failedAction: string): Toast ); } +export function createTenancyErrorToast(id: string, title: string, Message: string): Toast { + return createErrorToast(id, title, Message); +} + +export function createTenancySuccessToast(id: string, Title: string, Message: string): Toast { + return createSuccessToast(id, Title, Message); +} + export function useToastState(): [Toast[], (toAdd: Toast) => void, (toDelete: Toast) => void] { const [toasts, setToasts] = useState([]); const addToast = useCallback((toastToAdd: Toast) => { diff --git a/public/apps/types.ts b/public/apps/types.ts index 3f5c870b0..43d723563 100644 --- a/public/apps/types.ts +++ b/public/apps/types.ts @@ -14,13 +14,14 @@ */ import { AppMountParameters, CoreStart } from '../../../../src/core/public'; -import { SecurityPluginStartDependencies, ClientConfigType } from '../types'; +import { SecurityPluginStartDependencies, ClientConfigType, DashboardsInfo } from '../types'; export interface AppDependencies { coreStart: CoreStart; navigation: SecurityPluginStartDependencies; params: AppMountParameters; config: ClientConfigType; + dashboardsInfo: DashboardsInfo; } export interface BreadcrumbsPageDependencies extends AppDependencies { diff --git a/public/plugin.ts b/public/plugin.ts index 4fa66aaf4..d627bcece 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -47,6 +47,7 @@ import { import { addTenantToShareURL } from './services/shared-link'; import { interceptError } from './utils/logout-utils'; import { tenantColumn, getNamespacesToRegister } from './apps/configuration/utils/tenant-utils'; +import { getDashboardsInfoSafe } from './utils/dashboards-info-utils'; async function hasApiPermission(core: CoreSetup): Promise { try { @@ -88,6 +89,7 @@ export class SecurityPlugin const config = this.initializerContext.config.get(); const accountInfo = (await fetchAccountInfoSafe(core.http))?.data; + const multitenancyEnabled = (await getDashboardsInfoSafe(core.http))?.multitenancy_enabled; const isReadonly = accountInfo?.roles.some((role) => (config.readonly_mode?.roles || DEFAULT_READONLY_ROLES).includes(role) ); @@ -153,7 +155,7 @@ export class SecurityPlugin }) ); - if (config.multitenancy.enabled && config.multitenancy.enable_aggregation_view) { + if (multitenancyEnabled && config.multitenancy.enable_aggregation_view) { deps.savedObjectsManagement.columns.register( (tenantColumn as unknown) as SavedObjectsManagementColumn ); diff --git a/public/types.ts b/public/types.ts index 972ca244d..5e5a904f5 100644 --- a/public/types.ts +++ b/public/types.ts @@ -40,6 +40,12 @@ export interface AuthInfo { }; } +export interface DashboardsInfo { + multitenancy_enabled?: boolean; + private_tenant_enabled?: boolean; + default_tenant: string; +} + export interface ClientConfigType { readonly_mode: { roles: string[]; diff --git a/public/utils/dashboards-info-utils.tsx b/public/utils/dashboards-info-utils.tsx new file mode 100644 index 000000000..55804eb04 --- /dev/null +++ b/public/utils/dashboards-info-utils.tsx @@ -0,0 +1,29 @@ +/* + * 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 { HttpStart } from 'opensearch-dashboards/public'; +import { API_ENDPOINT_DASHBOARDSINFO } from '../../common'; +import { httpGet, httpGetWithIgnores } from '../apps/configuration/utils/request-utils'; +import { DashboardsInfo } from '../types'; +import { AccountInfo } from '../apps/account/types'; +import { API_ENDPOINT_ACCOUNT_INFO } from '../apps/account/constants'; + +export async function getDashboardsInfo(http: HttpStart) { + return await httpGet(http, API_ENDPOINT_DASHBOARDSINFO); +} + +export async function getDashboardsInfoSafe(http: HttpStart): Promise { + return httpGetWithIgnores(http, API_ENDPOINT_DASHBOARDSINFO, [401]); +} diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index 46066cf22..56ec21463 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -246,15 +246,19 @@ export abstract class AuthenticationType implements IAuthenticationType { } } - const selectedTenant = resolveTenant( + const dashboardsInfo = await this.securityClient.dashboardsinfo(request, authHeader); + + return resolveTenant({ request, - authInfo.user_name, - authInfo.roles, - authInfo.tenants, - this.config, - cookie - ); - return selectedTenant; + username: authInfo.user_name, + roles: authInfo.roles, + availabeTenants: authInfo.tenants, + config: this.config, + cookie, + multitenancyEnabled: dashboardsInfo.multitenancy_enabled, + privateTenantEnabled: dashboardsInfo.private_tenant_enabled, + defaultTenant: dashboardsInfo.default_tenant, + }); } isPageRequest(request: OpenSearchDashboardsRequest) { diff --git a/server/auth/types/basic/routes.ts b/server/auth/types/basic/routes.ts index a05f3878e..70ae5ee85 100755 --- a/server/auth/types/basic/routes.ts +++ b/server/auth/types/basic/routes.ts @@ -117,15 +117,19 @@ export class BasicAuthRoutes { expiryTime: Date.now() + this.config.session.ttl, }; - if (this.config.multitenancy?.enabled) { - const selectTenant = resolveTenant( + if (user.multitenancy_enabled) { + const selectTenant = resolveTenant({ request, - user.username, - user.roles, - user.tenants, - this.config, - sessionStorage - ); + username: user.username, + roles: user.roles, + availabeTenants: user.tenants, + config: this.config, + cookie: sessionStorage, + multitenancyEnabled: user.multitenancy_enabled, + privateTenantEnabled: user.private_tenant_enabled, + defaultTenant: user.default_tenant, + }); + // const selectTenant = user.default_tenant; sessionStorage.tenant = selectTenant; } this.sessionStorageFactory.asScoped(request).set(sessionStorage); @@ -203,15 +207,18 @@ export class BasicAuthRoutes { expiryTime: Date.now() + this.config.session.ttl, }; - if (this.config.multitenancy?.enabled) { - const selectTenant = resolveTenant( + if (user.multitenancy_enabled) { + const selectTenant = resolveTenant({ request, - user.username, - user.roles, - user.tenants, - this.config, - sessionStorage - ); + username: user.username, + roles: user.roles, + availabeTenants: user.tenants, + config: this.config, + cookie: sessionStorage, + multitenancyEnabled: user.multitenancy_enabled, + privateTenantEnabled: user.private_tenant_enabled, + defaultTenant: user.default_tenant, + }); sessionStorage.tenant = selectTenant; } this.sessionStorageFactory.asScoped(request).set(sessionStorage); diff --git a/server/auth/user.ts b/server/auth/user.ts index 3ab09dc0f..a00b49663 100644 --- a/server/auth/user.ts +++ b/server/auth/user.ts @@ -21,4 +21,7 @@ export interface User { selectedTenant?: string; credentials?: any; proxyCredentials?: any; + multitenancy_enabled?: boolean; + private_tenant_enabled?: boolean; + default_tenant?: string; } diff --git a/server/backend/opensearch_security_client.ts b/server/backend/opensearch_security_client.ts index 98befcfa4..79fcd7571 100755 --- a/server/backend/opensearch_security_client.ts +++ b/server/backend/opensearch_security_client.ts @@ -15,6 +15,8 @@ import { ILegacyClusterClient, OpenSearchDashboardsRequest } from '../../../../src/core/server'; import { User } from '../auth/user'; +import { getAuthInfo } from '../../public/utils/auth-info-utils'; +import { TenancyConfigSettings } from '../../public/apps/configuration/panels/tenancy-config/types'; export class SecurityClient { constructor(private readonly esClient: ILegacyClusterClient) {} @@ -39,6 +41,7 @@ export class SecurityClient { selectedTenant: esResponse.user_requested_tenant, credentials, proxyCredentials: credentials, + tenancy_configs: esResponse.tenancy_configs, }; } catch (error: any) { throw new Error(error.message); @@ -116,6 +119,18 @@ export class SecurityClient { } } + public async dashboardsinfo(request: OpenSearchDashboardsRequest, headers: any = {}) { + try { + return await this.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.dashboardsinfo', { + headers, + }); + } catch (error: any) { + throw new Error(error.message); + } + } + // Multi-tenancy APIs public async getMultitenancyInfo(request: OpenSearchDashboardsRequest) { try { @@ -127,6 +142,26 @@ export class SecurityClient { } } + public async putMultitenancyConfigurations( + request: OpenSearchDashboardsRequest, + tenancyConfigSettings: TenancyConfigSettings + ) { + const body = { + multitenancy_enabled: tenancyConfigSettings.multitenancy_enabled, + private_tenant_enabled: tenancyConfigSettings.private_tenant_enabled, + default_tenant: tenancyConfigSettings.default_tenant, + }; + try { + return await this.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.tenancy_configs', { + body, + }); + } catch (error: any) { + throw new Error(error.message); + } + } + public async getTenantInfoWithInternalUser() { try { return this.esClient.callAsInternalUser('opensearch_security.tenantinfo'); diff --git a/server/backend/opensearch_security_plugin.ts b/server/backend/opensearch_security_plugin.ts index e9a84c439..33b72cc0d 100644 --- a/server/backend/opensearch_security_plugin.ts +++ b/server/backend/opensearch_security_plugin.ts @@ -30,6 +30,12 @@ export default function (Client: any, config: any, components: any) { }, }); + Client.prototype.opensearch_security.prototype.dashboardsinfo = ca({ + url: { + fmt: '/_plugins/_security/dashboardsinfo', + }, + }); + /** * Gets tenant info and opensearch-dashboards server info. * @@ -70,4 +76,12 @@ export default function (Client: any, config: any, components: any) { fmt: '/_plugins/_security/api/authtoken', }, }); + + Client.prototype.opensearch_security.prototype.tenancy_configs = ca({ + method: 'PUT', + needBody: true, + url: { + fmt: '/_plugins/_security/api/tenancy/config', + }, + }); } diff --git a/server/multitenancy/routes.ts b/server/multitenancy/routes.ts index f5f75affc..d4dce3bf5 100644 --- a/server/multitenancy/routes.ts +++ b/server/multitenancy/routes.ts @@ -115,6 +115,37 @@ export function setupMultitenantRoutes( } ); + router.put( + { + path: '/api/v1/configuration/tenancy/config', + validate: { + body: schema.object({ + multitenancy_enabled: schema.boolean(), + private_tenant_enabled: schema.boolean(), + default_tenant: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const esResponse = await securityClient.putMultitenancyConfigurations( + request, + request.body + ); + return response.ok({ + body: esResponse, + headers: { + 'content-type': 'application/json', + }, + }); + } catch (error) { + return response.internalError({ + body: error.message, + }); + } + } + ); + router.post( { // FIXME: Seems this is not being used, confirm and delete if not used anymore diff --git a/server/multitenancy/tenant_resolver.ts b/server/multitenancy/tenant_resolver.ts index 7894e49db..e6c97a708 100755 --- a/server/multitenancy/tenant_resolver.ts +++ b/server/multitenancy/tenant_resolver.ts @@ -14,7 +14,6 @@ */ import { isEmpty, findKey, cloneDeep } from 'lodash'; -import { OpenSearchDashboardsRequest } from '../../../../src/core/server'; import { SecuritySessionCookie } from '../session/security_cookie'; import { SecurityPluginConfigType } from '..'; import { GLOBAL_TENANT_SYMBOL, PRIVATE_TENANT_SYMBOL, globalTenantName } from '../../common'; @@ -25,21 +24,39 @@ export const GLOBAL_TENANTS: string[] = ['global', GLOBAL_TENANT_SYMBOL]; * Resovles the tenant the user is using. * * @param request OpenSearchDashboards request. + * @param username + * @param roles + * @param availabeTenants * @param config security plugin config. * @param cookie cookie extracted from the request. The cookie should have been parsed by AuthenticationHandler. * pass it as parameter instead of extracting again. - * @param authInfo authentication info, the Elasticsearch authinfo API response. + * @param multitenancyEnabled + * @param privateTenantEnabled + * @param defaultTenant * * @returns user preferred tenant of the request. */ -export function resolveTenant( - request: OpenSearchDashboardsRequest, - username: string, - roles: string[] | undefined, - availabeTenants: any, - config: SecurityPluginConfigType, - cookie: SecuritySessionCookie -): string | undefined { +export function resolveTenant({ + request, + username, + roles, + availabeTenants, + config, + cookie, + multitenancyEnabled, + privateTenantEnabled, + defaultTenant, +}: { + request: any; + username: string; + roles: string[] | undefined; + availabeTenants: any; + config: SecurityPluginConfigType; + cookie: SecuritySessionCookie; + multitenancyEnabled: boolean; + privateTenantEnabled: boolean | undefined; + defaultTenant: string | undefined; +}): string | undefined { const DEFAULT_READONLY_ROLES = ['kibana_read_only']; let selectedTenant: string | undefined; const securityTenant_ = request?.url?.searchParams?.get('securityTenant_'); @@ -58,6 +75,8 @@ export function resolveTenant( : (request.headers.securityTenant_ as string); } else if (isValidTenant(cookie.tenant)) { selectedTenant = cookie.tenant; + } else if (defaultTenant && multitenancyEnabled) { + selectedTenant = defaultTenant; } else { selectedTenant = undefined; } @@ -67,7 +86,6 @@ export function resolveTenant( const preferredTenants = config.multitenancy?.tenants.preferred; const globalTenantEnabled = config.multitenancy?.tenants.enable_global; - const privateTenantEnabled = config.multitenancy?.tenants.enable_private && !isReadonly; return resolve( username, @@ -75,6 +93,7 @@ export function resolveTenant( preferredTenants, availabeTenants, globalTenantEnabled, + multitenancyEnabled, privateTenantEnabled ); } @@ -85,7 +104,8 @@ export function resolve( preferredTenants: string[] | undefined, availableTenants: any, // is an object like { tenant_name_1: true, tenant_name_2: false, ... } globalTenantEnabled: boolean, - privateTenantEnabled: boolean + multitenancyEnabled: boolean | undefined, + privateTenantEnabled: boolean | undefined ): string | undefined { const availableTenantsClone = cloneDeep(availableTenants); delete availableTenantsClone[username]; @@ -94,6 +114,13 @@ export function resolve( return undefined; } + if (!multitenancyEnabled) { + if (!globalTenantEnabled) { + return undefined; + } + return GLOBAL_TENANT_SYMBOL; + } + if (isValidTenant(requestedTenant)) { requestedTenant = requestedTenant!; if (requestedTenant in availableTenants) { diff --git a/server/multitenancy/test/tenant_resolver.test.ts b/server/multitenancy/test/tenant_resolver.test.ts index d8544d024..6cb735f80 100644 --- a/server/multitenancy/test/tenant_resolver.test.ts +++ b/server/multitenancy/test/tenant_resolver.test.ts @@ -23,6 +23,7 @@ describe("Resolve tenants when multitenancy is enabled and both 'Global' and 'Pr config.preferredTenants, config.availableTenants, config.globalTenantEnabled, + config.multitenancy_enabled, config.privateTenantEnabled ); } @@ -34,6 +35,7 @@ describe("Resolve tenants when multitenancy is enabled and both 'Global' and 'Pr preferredTenants: undefined, availableTenants: { global_tenant: true, admin_tenant: true, test_tenant: true, admin: true }, globalTenantEnabled: false, + multitenancy_enabled: true, privateTenantEnabled: false, }; @@ -48,10 +50,26 @@ describe("Resolve tenants when multitenancy is enabled and both 'Global' and 'Pr preferredTenants: undefined, availableTenants: { global_tenant: true, testuser: true }, globalTenantEnabled: false, + multitenancy_enabled: true, privateTenantEnabled: false, }; const nonadminResult = resolveWithConfig(nonadminConfig); expect(nonadminResult).toEqual('global_tenant'); }); + + it('Resolve tenant with multitenancy disabled and global tenant enabled', () => { + const adminConfig = { + username: 'admin', + requestedTenant: undefined, + preferredTenants: undefined, + availableTenants: { global_tenant: true, testuser: true }, + globalTenantEnabled: true, + multitenancy_enabled: false, + privateTenantEnabled: false, + }; + + const adminResult = resolveWithConfig(adminConfig); + expect(adminResult).toEqual(''); + }); }); diff --git a/server/routes/index.ts b/server/routes/index.ts index 56ce6507b..8331c1200 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -543,6 +543,30 @@ export function defineRoutes(router: IRouter) { } ); + router.get( + { + path: `${API_PREFIX}/auth/dashboardsinfo`, + validate: false, + }, + async ( + context, + request, + response + ): Promise> => { + const client = context.security_plugin.esClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opensearch_security.dashboardsinfo'); + + return response.ok({ + body: esResp, + }); + } catch (error) { + return errorResponse(response, error); + } + } + ); + /** * Gets audit log configuration。 *