From 154a3ba08828b36a8dae684651f25c8d72f584be Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Fri, 26 Apr 2024 12:33:01 -0400 Subject: [PATCH] Adds Multiple Datasources Support for Security Dashboards Plugin (#1888) Signed-off-by: Darshit Chanpura Signed-off-by: Derek Ho Co-authored-by: Darshit Chanpura Co-authored-by: Darshit Chanpura <35282393+DarshitChanpura@users.noreply.github.com> --- .github/actions/download-plugin/action.yml | 28 +- .github/actions/run-cypress-tests/action.yml | 16 +- ...ess-test-multidatasources-disabled-e2e.yml | 49 ++ ...ress-test-multidatasources-enabled-e2e.yml | 109 +++ .../cypress-test-tenancy-disabled.yml | 1 + .github/workflows/cypress-test.yml | 1 + .github/workflows/integration-test.yml | 26 +- .../workflows/verify-binary-installation.yml | 1 + common/index.ts | 2 + cypress.config.js | 4 + opensearch_dashboards.json | 4 +- public/apps/account/utils.tsx | 28 +- public/apps/configuration/app-router.tsx | 280 ++++---- .../apps/configuration/configuration-app.tsx | 14 +- .../audit-logging-edit-settings.tsx | 37 +- .../panels/audit-logging/audit-logging.tsx | 25 +- .../__snapshots__/audit-logging.test.tsx.snap | 19 +- .../test/audit-logging-edit-settings.test.tsx | 16 +- .../audit-logging/test/audit-logging.test.tsx | 4 + .../panels/auth-view/auth-view.tsx | 15 +- .../panels/auth-view/test/auth-view.test.tsx | 5 + .../apps/configuration/panels/get-started.tsx | 32 +- .../internal-user-edit/internal-user-edit.tsx | 38 +- .../test/internal-user-edit.test.tsx | 16 +- .../permission-list/permission-list.tsx | 49 +- .../test/permission-list.test.tsx | 45 +- .../role-edit/index-permission-panel.tsx | 10 +- .../panels/role-edit/role-edit.tsx | 59 +- .../panels/role-edit/tenant-panel.tsx | 11 +- .../test/index-permission-panel.test.tsx | 3 +- .../test/role-edit-filtering.test.tsx | 10 +- .../panels/role-edit/test/role-edit.test.tsx | 25 +- .../role-edit/test/tenant-panel.test.tsx | 3 +- .../apps/configuration/panels/role-list.tsx | 25 +- .../role-mapping/role-edit-mapped-user.tsx | 31 +- .../test/role-edit-mapped-user.test.tsx | 27 +- .../panels/role-view/role-view.tsx | 33 +- .../panels/role-view/tenants-panel.tsx | 3 +- .../__snapshots__/role-view.test.tsx.snap | 71 ++ .../panels/role-view/test/role-view.test.tsx | 24 +- .../panels/service-account-list.tsx | 16 +- .../panels/tenant-list/configure_tab1.tsx | 3 +- .../panels/tenant-list/manage_tab.tsx | 15 +- .../panels/tenant-list/tenant-list.tsx | 8 + .../tenant-list/test/tenant-list.test.tsx | 24 +- .../__snapshots__/get-started.test.tsx.snap | 46 ++ .../__snapshots__/role-list.test.tsx.snap | 1 + .../panels/test/get-started.test.tsx | 27 +- .../panels/test/role-list.test.tsx | 5 + .../panels/test/user-list.test.tsx | 4 + .../apps/configuration/panels/user-list.tsx | 27 +- .../__snapshots__/app-router.test.tsx.snap | 639 ++++++++++++++++++ .../configuration/test/app-router.test.tsx | 50 ++ .../configuration/test/top-nav-menu.test.tsx | 79 +++ public/apps/configuration/top-nav-menu.tsx | 67 ++ .../utils/action-groups-utils.tsx | 43 +- .../utils/audit-logging-utils.tsx | 24 +- .../configuration/utils/auth-view-utils.tsx | 9 +- .../configuration/utils/display-utils.tsx | 7 +- .../utils/internal-user-detail-utils.tsx | 22 +- .../utils/internal-user-list-utils.tsx | 31 +- .../apps/configuration/utils/request-utils.ts | 131 ++-- .../configuration/utils/role-detail-utils.tsx | 26 +- .../configuration/utils/role-list-utils.tsx | 29 +- .../utils/role-mapping-utils.tsx | 21 +- .../utils/tenancy-config_util.tsx | 11 +- .../apps/configuration/utils/tenant-utils.tsx | 54 +- .../__snapshots__/display-utils.test.tsx.snap | 2 + .../test/internal-user-list-utils.test.tsx | 63 +- .../utils/test/request-utils.test.ts | 48 ++ .../utils/test/toast-utils.test.tsx | 19 +- .../apps/configuration/utils/toast-utils.tsx | 16 +- public/apps/types.ts | 4 +- public/plugin.ts | 8 +- public/types.ts | 9 + public/utils/auth-info-utils.tsx | 7 +- public/utils/dashboards-info-utils.tsx | 13 +- public/utils/datasource-utils.ts | 41 ++ public/utils/login-utils.tsx | 12 +- public/utils/logout-utils.tsx | 7 +- public/utils/test/datasource-utils.test.ts | 81 +++ server/plugin.ts | 14 +- server/routes/index.ts | 173 +++-- .../multi_datasources_disabled.spec.js | 27 + .../multi_datasources_enabled.spec.js | 240 +++++++ test/cypress/support/commands.js | 14 + test/helper/entity_operation.ts | 45 ++ test/jest_integration/constants.ts | 74 ++ .../security_entity_api.test.ts | 506 +++++++++++++- 89 files changed, 3498 insertions(+), 543 deletions(-) create mode 100644 .github/workflows/cypress-test-multidatasources-disabled-e2e.yml create mode 100644 .github/workflows/cypress-test-multidatasources-enabled-e2e.yml create mode 100644 public/apps/configuration/test/__snapshots__/app-router.test.tsx.snap create mode 100644 public/apps/configuration/test/app-router.test.tsx create mode 100644 public/apps/configuration/test/top-nav-menu.test.tsx create mode 100644 public/apps/configuration/top-nav-menu.tsx create mode 100644 public/apps/configuration/utils/test/request-utils.test.ts create mode 100644 public/utils/datasource-utils.ts create mode 100644 public/utils/test/datasource-utils.test.ts create mode 100644 test/cypress/e2e/multi-datasources/multi_datasources_disabled.spec.js create mode 100644 test/cypress/e2e/multi-datasources/multi_datasources_enabled.spec.js create mode 100644 test/jest_integration/constants.ts diff --git a/.github/actions/download-plugin/action.yml b/.github/actions/download-plugin/action.yml index c97e2e9c1..cedcc87f8 100644 --- a/.github/actions/download-plugin/action.yml +++ b/.github/actions/download-plugin/action.yml @@ -14,6 +14,10 @@ inputs: description: 'The version of security plugin that should be used, e.g "3.0.0.0"' required: true + download-location: + description: 'The location of where to download the plugin' + required: true + runs: using: "composite" steps: @@ -22,26 +26,6 @@ runs: -DremoteRepositories=https://aws.oss.sonatype.org/content/repositories/snapshots/ \ -Dartifact=org.opensearch.plugin:${{ inputs.plugin-name }}:${{ inputs.plugin-version }}-SNAPSHOT:zip \ -Dtransitive=false \ - -Ddest=${{ inputs.plugin-name }}.zip + -Ddest=${{ inputs.download-location }}.zip shell: bash - - - name: Create Setup Script for Linux - if: ${{ runner.os == 'Linux' }} - run: | - cat > setup.sh <<'EOF' - chmod +x ./opensearch-${{ inputs.opensearch-version}}-SNAPSHOT/plugins/${{ inputs.plugin-name }}/tools/install_demo_configuration.sh - /bin/bash -c "yes | ./opensearch-${{ inputs.opensearch-version}}-SNAPSHOT/plugins/${{ inputs.plugin-name }}/tools/install_demo_configuration.sh -t" - echo "plugins.security.unsupported.restapi.allow_securityconfig_modification: true" >> ./opensearch-${{ inputs.opensearch-version }}-SNAPSHOT/config/opensearch.yml - echo "cluster.routing.allocation.disk.threshold_enabled: false" >> ./opensearch-${{ inputs.opensearch-version }}-SNAPSHOT/config/opensearch.yml - EOF - shell: bash - - - name: Create Setup Script for Windows - if: ${{ runner.os == 'Windows' }} - run: | - New-Item .\setup.bat -type file - Set-Content .\setup.bat -Value "powershell.exe -noexit -command `".\opensearch-${{ inputs.opensearch-version}}-SNAPSHOT\plugins\${{ inputs.plugin-name }}\tools\install_demo_configuration.bat -y -i -c -t`"" - Add-Content -Path .\setup.bat -Value "echo plugins.security.unsupported.restapi.allow_securityconfig_modification: true >> .\opensearch-${{ inputs.opensearch-version}}-SNAPSHOT\config\opensearch.yml" - Add-Content -Path .\setup.bat -Value "echo cluster.routing.allocation.disk.threshold_enabled: false >> .\opensearch-${{ inputs.opensearch-version}}-SNAPSHOT\config\opensearch.yml" - Get-Content .\setup.bat - shell: pwsh + \ No newline at end of file diff --git a/.github/actions/run-cypress-tests/action.yml b/.github/actions/run-cypress-tests/action.yml index 830b7ed20..eae07eac8 100644 --- a/.github/actions/run-cypress-tests/action.yml +++ b/.github/actions/run-cypress-tests/action.yml @@ -34,6 +34,7 @@ runs: opensearch-version: ${{ env.OPENSEARCH_VERSION }} plugin-name: ${{ env.PLUGIN_NAME }} plugin-version: ${{ env.PLUGIN_VERSION }} + download-location: ${{ env.PLUGIN_NAME }} - name: Run Opensearch with A Single Plugin uses: derek-ho/start-opensearch@v2 @@ -62,7 +63,7 @@ runs: if: ${{ runner.os == 'Linux' }} run: | cd ./OpenSearch-Dashboards - nohup yarn start --no-base-path --no-watch | tee dashboard.log & + nohup yarn start --no-base-path --no-watch --csp.warnLegacyBrowsers=false | tee dashboard.log & shell: bash # Check if OSD is ready with a max timeout of 600 seconds @@ -84,8 +85,11 @@ runs: done shell: bash - - name: Run Cypress - run : | - yarn add cypress --save-dev - eval ${{ inputs.yarn_command }} - shell: bash + - name: Run Cypress Tests with retry + uses: Wandalen/wretry.action@v3.3.0 + with: + attempt_limit: 5 + attempt_delay: 2000 + command: | + yarn add cypress --save-dev + eval ${{ inputs.yarn_command }} diff --git a/.github/workflows/cypress-test-multidatasources-disabled-e2e.yml b/.github/workflows/cypress-test-multidatasources-disabled-e2e.yml new file mode 100644 index 000000000..e41de5fab --- /dev/null +++ b/.github/workflows/cypress-test-multidatasources-disabled-e2e.yml @@ -0,0 +1,49 @@ +name: E2E multi datasources disabled workflow + +on: [ push, pull_request ] + +env: + OPENSEARCH_VERSION: '3.0.0' + CI: 1 + # avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + PLUGIN_NAME: opensearch-security + OPENSEARCH_INITIAL_ADMIN_PASSWORD: myStrongPassword123! + +jobs: + tests: + name: Run Cypress multidatasources tests + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout Branch + uses: actions/checkout@v3 + + # Configure the Dashboard for multi datasources disabled (default) + - name: Create OpenSearch Dashboards Config + if: ${{ runner.os == 'Linux' }} + run: | + cat << 'EOT' > opensearch_dashboards_multidatasources.yml + server.host: "0.0.0.0" + opensearch.hosts: ["https://localhost:9200"] + opensearch.ssl.verificationMode: none + opensearch.username: "kibanaserver" + opensearch.password: "kibanaserver" + opensearch.requestHeadersWhitelist: [ authorization,securitytenant ] + opensearch_security.multitenancy.enabled: false + opensearch_security.multitenancy.tenants.preferred: ["Private", "Global"] + opensearch_security.readonly_mode.roles: ["kibana_read_only"] + opensearch_security.cookie.secure: false + data_source.enabled: false + home.disableWelcomeScreen: true + EOT + + - name: Run Cypress Tests + uses: ./.github/actions/run-cypress-tests + with: + dashboards_config_file: opensearch_dashboards_multidatasources.yml + yarn_command: 'yarn cypress:run --browser chrome --headless --env LOGIN_AS_ADMIN=true --spec "test/cypress/e2e/multi-datasources/multi_datasources_disabled.spec.js"' diff --git a/.github/workflows/cypress-test-multidatasources-enabled-e2e.yml b/.github/workflows/cypress-test-multidatasources-enabled-e2e.yml new file mode 100644 index 000000000..162941cf1 --- /dev/null +++ b/.github/workflows/cypress-test-multidatasources-enabled-e2e.yml @@ -0,0 +1,109 @@ +name: E2E multi datasources enabled workflow + +on: [ push, pull_request ] + +env: + OPENSEARCH_VERSION: '3.0.0' + CI: 1 + # avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + PLUGIN_NAME: opensearch-security + OPENSEARCH_INITIAL_ADMIN_PASSWORD: myStrongPassword123! + +jobs: + tests: + name: Run Cypress multidatasources tests + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout Branch + uses: actions/checkout@v3 + + - name: Set env + run: | + opensearch_version=$(node -p "require('./package.json').opensearchDashboards.version") + plugin_version=$(node -p "require('./package.json').version") + echo "OPENSEARCH_VERSION=$opensearch_version" >> $GITHUB_ENV + echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV + shell: bash + + # Add Custom Configuration to differentiate between local and remote cluster + - name: Create Custom Configuration for Linux + if: ${{ runner.os == 'Linux'}} + run: | + echo "Creating new custom configuration" + cat << 'EOT' > config_custom.yml + --- + _meta: + type: "config" + config_version: 2 + config: + dynamic: + http: + anonymous_auth_enabled: false + authc: + basic_internal_auth_domain: + description: "Authenticate via HTTP Basic against internal users database" + http_enabled: true + transport_enabled: true + order: 0 + http_authenticator: + type: basic + challenge: false + authentication_backend: + type: intern + EOT + + - name: Download security plugin and create setup scripts + uses: ./.github/actions/download-plugin + with: + opensearch-version: ${{ env.OPENSEARCH_VERSION }} + plugin-name: ${{ env.PLUGIN_NAME }} + plugin-version: ${{ env.PLUGIN_VERSION }} + download-location: ${{env.PLUGIN_NAME}} + + - name: Run Opensearch with A Single Plugin + uses: derek-ho/start-opensearch@v4 + with: + opensearch-version: ${{ env.OPENSEARCH_VERSION }} + plugins: "file:$(pwd)/opensearch-security.zip" + security-enabled: true + admin-password: ${{ env.OPENSEARCH_INITIAL_ADMIN_PASSWORD }} + security_config_file: config_custom.yml + port: 9202 + + - name: Check OpenSearch is running + # Verify that the server is operational + run: | + curl https://localhost:9202/_cat/plugins -v -u admin:${{ env.OPENSEARCH_INITIAL_ADMIN_PASSWORD }} -k + shell: bash + + # Configure the Dashboard for multi datasources + - name: Create OpenSearch Dashboards Config + if: ${{ runner.os == 'Linux' }} + run: | + cat << 'EOT' > opensearch_dashboards_multidatasources.yml + server.host: "localhost" + opensearch.hosts: ["https://localhost:9200"] + opensearch.ssl.verificationMode: none + opensearch.username: "kibanaserver" + opensearch.password: "kibanaserver" + opensearch.requestHeadersWhitelist: [ authorization,securitytenant ] + opensearch_security.multitenancy.enabled: true + opensearch_security.multitenancy.tenants.preferred: ["Private", "Global"] + opensearch_security.readonly_mode.roles: ["kibana_read_only"] + opensearch_security.cookie.secure: false + data_source.enabled: true + home.disableWelcomeScreen: true + data_source.ssl.verificationMode: none + EOT + + - name: Run Cypress Tests + uses: ./.github/actions/run-cypress-tests + with: + dashboards_config_file: opensearch_dashboards_multidatasources.yml + yarn_command: 'yarn cypress:run --browser chrome --headless --env LOGIN_AS_ADMIN=true --spec "test/cypress/e2e/multi-datasources/multi_datasources_enabled.spec.js"' diff --git a/.github/workflows/cypress-test-tenancy-disabled.yml b/.github/workflows/cypress-test-tenancy-disabled.yml index 8ba25a4c8..024dd9cbc 100644 --- a/.github/workflows/cypress-test-tenancy-disabled.yml +++ b/.github/workflows/cypress-test-tenancy-disabled.yml @@ -44,6 +44,7 @@ jobs: opensearch-version: ${{ env.OPENSEARCH_VERSION }} plugin-name: ${{ env.PLUGIN_NAME }} plugin-version: ${{ env.PLUGIN_VERSION }} + download-location: ${{ env.PLUGIN_NAME }} - name: Run Opensearch with security uses: derek-ho/start-opensearch@v2 diff --git a/.github/workflows/cypress-test.yml b/.github/workflows/cypress-test.yml index ab5000383..c9a85ac6f 100644 --- a/.github/workflows/cypress-test.yml +++ b/.github/workflows/cypress-test.yml @@ -44,6 +44,7 @@ jobs: opensearch-version: ${{ env.OPENSEARCH_VERSION }} plugin-name: ${{ env.PLUGIN_NAME }} plugin-version: ${{ env.PLUGIN_VERSION }} + download-location: ${{ env.PLUGIN_NAME }} - name: Run Opensearch with security uses: derek-ho/start-opensearch@v2 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index fd328ed54..2126ff1d0 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -34,13 +34,37 @@ jobs: echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV shell: bash - - name: Download security plugin and create setup scripts + - name: Download security plugin and create setup scripts for remote cluster uses: ./.github/actions/download-plugin with: opensearch-version: ${{ env.OPENSEARCH_VERSION }} plugin-name: ${{ env.PLUGIN_NAME }} + download-location: ${{env.PLUGIN_NAME}}-${{ env.OPENSEARCH_VERSION }} plugin-version: ${{ env.PLUGIN_VERSION }} + - name: Download security plugin and create setup scripts for local cluster + uses: ./.github/actions/download-plugin + with: + opensearch-version: ${{ env.OPENSEARCH_VERSION }} + plugin-name: ${{ env.PLUGIN_NAME }} + download-location: ${{env.PLUGIN_NAME}} + plugin-version: ${{ env.PLUGIN_VERSION }} + + - name: Run Opensearch with A Single Plugin Remote Cluster + uses: derek-ho/start-opensearch@v4 + with: + opensearch-version: ${{ env.OPENSEARCH_VERSION }} + plugins: "file:$(pwd)/opensearch-security-${{ env.OPENSEARCH_VERSION }}.zip" + security-enabled: true + admin-password: ${{ env.OPENSEARCH_INITIAL_ADMIN_PASSWORD }} + security_config_file: ${{ inputs.security_config_file }} + port: 9202 + + - name: Check OpenSearch remote is running + run: | + curl https://localhost:9202/_cat/plugins -v -u admin:${{ env.OPENSEARCH_INITIAL_ADMIN_PASSWORD }} -k + shell: bash + - name: Run Opensearch with security uses: derek-ho/start-opensearch@v2 with: diff --git a/.github/workflows/verify-binary-installation.yml b/.github/workflows/verify-binary-installation.yml index 7c89029d8..c19bc3c81 100644 --- a/.github/workflows/verify-binary-installation.yml +++ b/.github/workflows/verify-binary-installation.yml @@ -36,6 +36,7 @@ jobs: opensearch-version: ${{ env.OPENSEARCH_VERSION }} plugin-name: ${{ env.PLUGIN_NAME }} plugin-version: ${{ env.PLUGIN_VERSION }} + download-location: ${{ env.PLUGIN_NAME }} - name: Run Opensearch with security uses: derek-ho/start-opensearch@v2 diff --git a/common/index.ts b/common/index.ts index 1a0eb3ff5..84445486d 100644 --- a/common/index.ts +++ b/common/index.ts @@ -56,6 +56,8 @@ export const MAX_INTEGER = 2147483647; export const MAX_LENGTH_OF_COOKIE_BYTES = 4000; export const ESTIMATED_IRON_COOKIE_OVERHEAD = 1.5; +export const LOCAL_CLUSTER_ID = ''; + export enum AuthType { BASIC = 'basicauth', OPEN_ID = 'openid', diff --git a/cypress.config.js b/cypress.config.js index e5a86728b..6f619e800 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -31,5 +31,9 @@ module.exports = defineConfig({ openSearchUrl: 'https://localhost:9200', adminUserName: 'admin', adminPassword: 'myStrongPassword123!', + externalDataSourceAdminUserName: 'admin', + externalDataSourceAdminPassword: 'myStrongPassword123!', + externalDataSourceLabel: '9202', + externalDataSourceEndpoint: 'https://localhost:9202' }, }); diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 994096906..3a2642593 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -10,7 +10,9 @@ "savedObjectsManagement" ], "optionalPlugins": [ - "managementOverview" + "managementOverview", + "dataSource", + "dataSourceManagement" ], "server": true, "ui": true diff --git a/public/apps/account/utils.tsx b/public/apps/account/utils.tsx index b4146e7f4..ab41bd34a 100644 --- a/public/apps/account/utils.tsx +++ b/public/apps/account/utils.tsx @@ -16,20 +16,30 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { API_AUTH_LOGOUT } from '../../../common'; import { setShouldShowTenantPopup } from '../../utils/storage-utils'; -import { httpGet, httpGetWithIgnores, httpPost } from '../configuration/utils/request-utils'; import { API_ENDPOINT_ACCOUNT_INFO } from './constants'; import { AccountInfo } from './types'; +import { createLocalClusterRequestContext } from '../configuration/utils/request-utils'; export function fetchAccountInfo(http: HttpStart): Promise { - return httpGet(http, API_ENDPOINT_ACCOUNT_INFO); + return createLocalClusterRequestContext().httpGet({ + http, + url: API_ENDPOINT_ACCOUNT_INFO, + }); } export async function fetchAccountInfoSafe(http: HttpStart): Promise { - return httpGetWithIgnores(http, API_ENDPOINT_ACCOUNT_INFO, [401]); + return createLocalClusterRequestContext().httpGetWithIgnores({ + http, + url: API_ENDPOINT_ACCOUNT_INFO, + ignores: [401], + }); } export async function logout(http: HttpStart, logoutUrl?: string): Promise { - await httpPost(http, API_AUTH_LOGOUT); + await createLocalClusterRequestContext().httpPost({ + http, + url: API_AUTH_LOGOUT, + }); setShouldShowTenantPopup(null); // Clear everything in the sessionStorage since they can contain sensitive information sessionStorage.clear(); @@ -52,8 +62,12 @@ export async function updateNewPassword( newPassword: string, currentPassword: string ): Promise { - await httpPost(http, API_ENDPOINT_ACCOUNT_INFO, { - password: newPassword, - current_password: currentPassword, + await createLocalClusterRequestContext().httpPost({ + http, + url: API_ENDPOINT_ACCOUNT_INFO, + body: { + password: newPassword, + current_password: currentPassword, + }, }); } diff --git a/public/apps/configuration/app-router.tsx b/public/apps/configuration/app-router.tsx index e063463f0..8e23a3065 100644 --- a/public/apps/configuration/app-router.tsx +++ b/public/apps/configuration/app-router.tsx @@ -15,8 +15,9 @@ import { EuiBreadcrumb, EuiPage, EuiPageBody, EuiPageSideBar } from '@elastic/eui'; import { flow, partial } from 'lodash'; -import React from 'react'; +import React, { createContext, useState } from 'react'; import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; +import { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types'; import { AppDependencies } from '../types'; import { AuditLogging } from './panels/audit-logging/audit-logging'; import { AuditLoggingEditSettings } from './panels/audit-logging/audit-logging-edit-settings'; @@ -40,6 +41,7 @@ import { Action, RouteItem, SubAction } from './types'; import { ResourceType } from '../../../common'; import { buildHashUrl, buildUrl } from './utils/url-builder'; import { CrossPageToast } from './cross-page-toast'; +import { getDataSourceFromUrl } from '../../utils/datasource-utils'; const LANDING_PAGE_URL = '/getstarted'; @@ -143,140 +145,154 @@ function decodeParams(params: { [k: string]: string }): any { }, {}); } +export interface DataSourceContextType { + dataSource: DataSourceOption; + setDataSource: React.Dispatch>; +} + +export const LocalCluster = { label: 'Local cluster', id: '' }; + +export const DataSourceContext = createContext(null); + export function AppRouter(props: AppDependencies) { const setGlobalBreadcrumbs = flow(getBreadcrumbs, props.coreStart.chrome.setBreadcrumbs); + const dataSourceFromUrl = getDataSourceFromUrl(); + + const [dataSource, setDataSource] = useState(dataSourceFromUrl); return ( - - - {allNavPanelUrls.map((route) => ( - // Create different routes to update the 'selected' nav item . - - - - - - ))} - - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - { - setGlobalBreadcrumbs(ResourceType.roles); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.auth); - return ; - }} - /> - ( - - )} - /> - { - setGlobalBreadcrumbs(ResourceType.users); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.serviceAccounts); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.auditLogging, 'General settings'); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.auditLogging, 'Compliance settings'); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.auditLogging); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.permissions); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.tenants); - return ; - }} - /> - { - setGlobalBreadcrumbs(ResourceType.tenants); - return ; - }} - /> - { - setGlobalBreadcrumbs(); - return ; - }} - /> - - - - - - + + + + {allNavPanelUrls.map((route) => ( + // Create different routes to update the 'selected' nav item . + + + + + + ))} + + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + { + setGlobalBreadcrumbs(ResourceType.roles); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.auth); + return ; + }} + /> + ( + + )} + /> + { + setGlobalBreadcrumbs(ResourceType.users); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.serviceAccounts); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.auditLogging, 'General settings'); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.auditLogging, 'Compliance settings'); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.auditLogging); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.permissions); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.tenants); + return ; + }} + /> + { + setGlobalBreadcrumbs(ResourceType.tenants); + return ; + }} + /> + { + setGlobalBreadcrumbs(); + return ; + }} + /> + + + + + + + ); } diff --git a/public/apps/configuration/configuration-app.tsx b/public/apps/configuration/configuration-app.tsx index a2294315d..1f341f781 100644 --- a/public/apps/configuration/configuration-app.tsx +++ b/public/apps/configuration/configuration-app.tsx @@ -21,14 +21,22 @@ import { I18nProvider } from '@osd/i18n/react'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; import { SecurityPluginStartDependencies, ClientConfigType } from '../../types'; import { AppRouter } from './app-router'; +import { DataSourceManagementPluginSetup } from '../../../../../src/plugins/data_source_management/public'; export function renderApp( coreStart: CoreStart, - navigation: SecurityPluginStartDependencies, + depsStart: SecurityPluginStartDependencies, params: AppMountParameters, - config: ClientConfigType + config: ClientConfigType, + dataSourceManagement?: DataSourceManagementPluginSetup ) { - const deps = { coreStart, navigation, params, config }; + const deps = { + coreStart, + depsStart, + params, + config, + dataSourceManagement, + }; ReactDOM.render( 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 de5d0086f..8e89d62a7 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 @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { EuiButton, EuiFlexGroup, @@ -35,15 +35,20 @@ import { ResourceType } from '../../../../../common'; import { getAuditLogging, updateAuditLogging } from '../../utils/audit-logging-utils'; import { useToastState } from '../../utils/toast-utils'; import { setCrossPageToast } from '../../utils/storage-utils'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; +import { DataSourceContext } from '../../app-router'; +import { getClusterInfo } from '../../../../utils/datasource-utils'; interface AuditLoggingEditSettingProps extends AppDependencies { setting: 'general' | 'compliance'; } export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; const [editConfig, setEditConfig] = React.useState({}); const [toasts, addToast, removeToast] = useToastState(); const [invalidSettings, setInvalidSettings] = React.useState([]); + const { dataSource, setDataSource } = useContext(DataSourceContext)!; const handleChange = (path: string, val: boolean | string[] | SettingMapItem) => { setEditConfig((previousEditedConfig) => { @@ -63,7 +68,7 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { React.useEffect(() => { const fetchConfig = async () => { try { - const fetchedConfig = await getAuditLogging(props.coreStart.http); + const fetchedConfig = await getAuditLogging(props.coreStart.http, dataSource.id); setEditConfig(fetchedConfig); } catch (e) { console.log(e); @@ -71,7 +76,7 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { }; fetchConfig(); - }, [props.coreStart.http]); + }, [props.coreStart.http, dataSource.id]); const renderSaveAndCancel = () => { return ( @@ -106,7 +111,7 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { const saveConfig = async (configToUpdate: AuditLoggingSettings) => { try { - await updateAuditLogging(props.coreStart.http, configToUpdate); + await updateAuditLogging(props.coreStart.http, configToUpdate, dataSource.id); const addSuccessToast = (text: string) => { const successToast: Toast = { @@ -121,9 +126,11 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { }; if (props.setting === 'general') { - addSuccessToast('General settings saved'); + addSuccessToast(`General settings saved ${getClusterInfo(dataSourceEnabled, dataSource)}`); } else { - addSuccessToast('Compliance settings saved'); + addSuccessToast( + `Compliance settings saved ${getClusterInfo(dataSourceEnabled, dataSource)}` + ); } window.location.href = buildHashUrl(ResourceType.auditLogging); @@ -132,7 +139,11 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { id: 'update-result', color: 'danger', iconType: 'alert', - title: 'Failed to update audit configuration due to ' + e?.message, + title: + `Failed to update audit configuration ${getClusterInfo( + dataSourceEnabled, + dataSource + )} due to ` + e?.message, }; addToast(failureToast); @@ -237,5 +248,15 @@ export function AuditLoggingEditSettings(props: AuditLoggingEditSettingProps) { content = renderComplianceSetting(); } - return
{content}
; + return ( +
+ + {content} +
+ ); } diff --git a/public/apps/configuration/panels/audit-logging/audit-logging.tsx b/public/apps/configuration/panels/audit-logging/audit-logging.tsx index 5ca10c84c..7b3bd5429 100644 --- a/public/apps/configuration/panels/audit-logging/audit-logging.tsx +++ b/public/apps/configuration/panels/audit-logging/audit-logging.tsx @@ -28,7 +28,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import React from 'react'; +import React, { useContext } from 'react'; import { FormattedMessage } from '@osd/i18n/react'; import { AppDependencies } from '../../../types'; import { ResourceType } from '../../../../../common'; @@ -43,6 +43,8 @@ import { import { AuditLoggingSettings } from './types'; import { ViewSettingGroup } from './view-setting-group'; import { DocLinks } from '../../constants'; +import { DataSourceContext } from '../../app-router'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; interface AuditLoggingProps extends AppDependencies { fromType: string; @@ -134,13 +136,14 @@ export function renderComplianceSettings(config: AuditLoggingSettings) { export function AuditLogging(props: AuditLoggingProps) { const [configuration, setConfiguration] = React.useState({}); + const { dataSource, setDataSource } = useContext(DataSourceContext)!; const onSwitchChange = async () => { try { const updatedConfiguration = { ...configuration }; updatedConfiguration.enabled = !updatedConfiguration.enabled; - await updateAuditLogging(props.coreStart.http, updatedConfiguration); + await updateAuditLogging(props.coreStart.http, updatedConfiguration, dataSource.id); setConfiguration(updatedConfiguration); } catch (e) { @@ -151,7 +154,7 @@ export function AuditLogging(props: AuditLoggingProps) { React.useEffect(() => { const fetchData = async () => { try { - const auditLogging = await getAuditLogging(props.coreStart.http); + const auditLogging = await getAuditLogging(props.coreStart.http, dataSource.id); setConfiguration(auditLogging); } catch (e) { // TODO: switch to better error handling. @@ -160,7 +163,7 @@ export function AuditLogging(props: AuditLoggingProps) { }; fetchData(); - }, [props.coreStart.http, props.fromType]); + }, [props.coreStart.http, props.fromType, dataSource.id]); const statusPanel = renderStatusPanel(onSwitchChange, configuration.enabled || false); @@ -174,7 +177,7 @@ export function AuditLogging(props: AuditLoggingProps) { {statusPanel} - + @@ -226,5 +229,15 @@ export function AuditLogging(props: AuditLoggingProps) { ); } - return
{content}
; + return ( +
+ + {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 6d8e9167f..9fdee791a 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 @@ -241,6 +241,21 @@ exports[`Audit logs render when AuditLoggingSettings.enabled is true 1`] = `
+

@@ -321,7 +336,9 @@ exports[`Audit logs render when AuditLoggingSettings.enabled is true 1`] = ` - + diff --git a/public/apps/configuration/panels/audit-logging/test/audit-logging-edit-settings.test.tsx b/public/apps/configuration/panels/audit-logging/test/audit-logging-edit-settings.test.tsx index a7b52d428..ead990e33 100644 --- a/public/apps/configuration/panels/audit-logging/test/audit-logging-edit-settings.test.tsx +++ b/public/apps/configuration/panels/audit-logging/test/audit-logging-edit-settings.test.tsx @@ -21,6 +21,10 @@ import { buildHashUrl } from '../../../utils/url-builder'; import { ResourceType } from '../../../../../../common'; jest.mock('../../../utils/audit-logging-utils'); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); // eslint-disable-next-line const mockAuditLoggingUtils = require('../../../utils/audit-logging-utils'); @@ -53,7 +57,7 @@ describe('Audit logs edit', () => { shallow( { shallow( { shallow( { const component = shallow( { const component = shallow( { const component = shallow( ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); // eslint-disable-next-line const mockAuditLoggingUtils = require('../../../utils/audit-logging-utils'); diff --git a/public/apps/configuration/panels/auth-view/auth-view.tsx b/public/apps/configuration/panels/auth-view/auth-view.tsx index 80a8651bf..7a5eb4b9d 100644 --- a/public/apps/configuration/panels/auth-view/auth-view.tsx +++ b/public/apps/configuration/panels/auth-view/auth-view.tsx @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { EuiPageHeader, EuiSpacer, EuiTitle } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { AuthenticationSequencePanel } from './authentication-sequence-panel'; @@ -23,17 +23,20 @@ import { AppDependencies } from '../../../types'; import { ExternalLinkButton } from '../../utils/display-utils'; import { getSecurityConfig } from '../../utils/auth-view-utils'; import { InstructionView } from './instruction-view'; +import { DataSourceContext } from '../../app-router'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; export function AuthView(props: AppDependencies) { const [authentication, setAuthentication] = React.useState([]); const [authorization, setAuthorization] = React.useState([]); const [loading, setLoading] = useState(false); + const { dataSource, setDataSource } = useContext(DataSourceContext)!; React.useEffect(() => { const fetchData = async () => { try { setLoading(true); - const config = await getSecurityConfig(props.coreStart.http); + const config = await getSecurityConfig(props.coreStart.http, dataSource.id); setAuthentication(config.authc); setAuthorization(config.authz); @@ -45,7 +48,7 @@ export function AuthView(props: AppDependencies) { }; fetchData(); - }, [props.coreStart.http]); + }, [props.coreStart.http, dataSource.id]); if (isEmpty(authentication)) { return ; @@ -53,6 +56,12 @@ export function AuthView(props: AppDependencies) { return ( <> +

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 398204969..8a97113ec 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 @@ -17,6 +17,11 @@ import { shallow } from 'enzyme'; import React from 'react'; import { AuthView } from '../auth-view'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); + // eslint-disable-next-line const mockAuthViewUtils = require('../../../utils/auth-view-utils'); diff --git a/public/apps/configuration/panels/get-started.tsx b/public/apps/configuration/panels/get-started.tsx index 806d8d2f4..4d3b8c461 100644 --- a/public/apps/configuration/panels/get-started.tsx +++ b/public/apps/configuration/panels/get-started.tsx @@ -26,7 +26,7 @@ import { EuiTitle, EuiGlobalToastList, } from '@elastic/eui'; -import React from 'react'; +import React, { useContext } from 'react'; import { FormattedMessage } from '@osd/i18n/react'; import { AppDependencies } from '../../types'; import { buildHashUrl } from '../utils/url-builder'; @@ -34,8 +34,11 @@ import { Action } from '../types'; import { ResourceType } from '../../../../common'; import { API_ENDPOINT_CACHE, DocLinks } from '../constants'; import { ExternalLink, ExternalLinkButton } from '../utils/display-utils'; -import { httpDelete } from '../utils/request-utils'; +import { createRequestContextWithDataSourceId } from '../utils/request-utils'; import { createSuccessToast, createUnknownErrorToast, useToastState } from '../utils/toast-utils'; +import { SecurityPluginTopNavMenu } from '../top-nav-menu'; +import { DataSourceContext } from '../app-router'; +import { getClusterInfo } from '../../../utils/datasource-utils'; const addBackendStep = { title: 'Add backends', @@ -159,6 +162,9 @@ const setOfSteps = [ ]; export function GetStarted(props: AppDependencies) { + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; + const { dataSource, setDataSource } = useContext(DataSourceContext)!; + let steps; if (props.config.ui.backend_configurable) { steps = [addBackendStep, ...setOfSteps]; @@ -170,6 +176,12 @@ export function GetStarted(props: AppDependencies) { return ( <>
+

Get started

@@ -236,16 +248,24 @@ export function GetStarted(props: AppDependencies) { data-test-subj="purge-cache" onClick={async () => { try { - await httpDelete(props.coreStart.http, API_ENDPOINT_CACHE); + await createRequestContextWithDataSourceId(dataSource.id).httpDelete({ + http: props.coreStart.http, + url: API_ENDPOINT_CACHE, + }); addToast( createSuccessToast( 'cache-flush-success', - 'Cache purge successful', - 'Cache purge successful' + `Cache purge successful ${getClusterInfo(dataSourceEnabled, dataSource)}`, + `Cache purge successful ${getClusterInfo(dataSourceEnabled, dataSource)}` ) ); } catch (err) { - addToast(createUnknownErrorToast('cache-flush-failed', 'purge cache')); + addToast( + createUnknownErrorToast( + 'cache-flush-failed', + `purge cache ${getClusterInfo(dataSourceEnabled, dataSource)}` + ) + ); } }} > 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 1237ef9f3..70ba39be8 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 @@ -24,7 +24,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { BreadcrumbsPageDependencies } from '../../../types'; import { InternalUserUpdate } from '../../types'; import { ResourceType } from '../../../../../common'; @@ -47,6 +47,8 @@ import { NameRow } from '../../utils/name-row'; import { DocLinks } from '../../constants'; import { constructErrorMessageAndLog } from '../../../error-utils'; import { BackendRolePanel } from './backend-role-panel'; +import { DataSourceContext } from '../../app-router'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; interface InternalUserEditDeps extends BreadcrumbsPageDependencies { action: 'create' | 'edit' | 'duplicate'; @@ -72,13 +74,19 @@ export function InternalUserEdit(props: InternalUserEditDeps) { const [toasts, addToast, removeToast] = useToastState(); const [isFormValid, setIsFormValid] = useState(true); + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; + const { dataSource, setDataSource } = useContext(DataSourceContext)!; React.useEffect(() => { const action = props.action; if (action === 'edit' || action === 'duplicate') { const fetchData = async () => { try { - const user = await getUserDetail(props.coreStart.http, props.sourceUserName); + const user = await getUserDetail( + props.coreStart.http, + props.sourceUserName, + dataSource.id + ); setAttributes(buildAttributeState(user.attributes)); setBackendRoles(user.backend_roles); setUserName(generateResourceName(action, props.sourceUserName)); @@ -90,7 +98,7 @@ export function InternalUserEdit(props: InternalUserEditDeps) { fetchData(); } - }, [addToast, props.action, props.coreStart.http, props.sourceUserName]); + }, [addToast, props.action, props.coreStart.http, props.sourceUserName, dataSource.id]); const updateUserHandler = async () => { try { @@ -117,12 +125,18 @@ export function InternalUserEdit(props: InternalUserEditDeps) { updateObject.password = password; } - await updateUser(props.coreStart.http, userName, updateObject); + await updateUser(props.coreStart.http, userName, updateObject, dataSource.id); setCrossPageToast(buildUrl(ResourceType.users), { id: 'updateUserSucceeded', color: 'success', - title: getSuccessToastMessage('User', props.action, userName), + title: `${getSuccessToastMessage( + 'User', + props.action, + userName, + dataSourceEnabled, + dataSource + )}`, }); // Redirect to user listing window.location.href = buildHashUrl(ResourceType.users); @@ -135,6 +149,12 @@ export function InternalUserEdit(props: InternalUserEditDeps) { return ( <> + {props.buildBreadcrumbs(TITLE_TEXT_DICT[props.action])} @@ -187,7 +207,13 @@ export function InternalUserEdit(props: InternalUserEditDeps) { - + {props.action === 'edit' ? 'Save changes' : 'Create'} diff --git a/public/apps/configuration/panels/internal-user-edit/test/internal-user-edit.test.tsx b/public/apps/configuration/panels/internal-user-edit/test/internal-user-edit.test.tsx index d8d5efc5d..241f4e14f 100644 --- a/public/apps/configuration/panels/internal-user-edit/test/internal-user-edit.test.tsx +++ b/public/apps/configuration/panels/internal-user-edit/test/internal-user-edit.test.tsx @@ -31,6 +31,10 @@ jest.mock('../../../utils/toast-utils', () => ({ createUnknownErrorToast: jest.fn(), useToastState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]), })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); describe('Internal user edit', () => { const sampleUsername = 'user1'; @@ -52,7 +56,7 @@ describe('Internal user edit', () => { sourceUserName={sampleUsername} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -73,13 +77,13 @@ describe('Internal user edit', () => { sourceUserName={sampleUsername} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> ); - expect(getUserDetail).toBeCalledWith(mockCoreStart.http, sampleUsername); + expect(getUserDetail).toBeCalledWith(mockCoreStart.http, sampleUsername, 'test'); }); it('should not submit if password is empty on creation', () => { @@ -92,7 +96,7 @@ describe('Internal user edit', () => { sourceUserName={sampleUsername} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -112,7 +116,7 @@ describe('Internal user edit', () => { sourceUserName={sampleUsername} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -134,7 +138,7 @@ describe('Internal user edit', () => { sourceUserName={sampleUsername} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> diff --git a/public/apps/configuration/panels/permission-list/permission-list.tsx b/public/apps/configuration/panels/permission-list/permission-list.tsx index 96f0c425b..e89bb2d03 100644 --- a/public/apps/configuration/panels/permission-list/permission-list.tsx +++ b/public/apps/configuration/panels/permission-list/permission-list.tsx @@ -34,7 +34,14 @@ import { Query, } from '@elastic/eui'; import { difference } from 'lodash'; -import React, { Dispatch, ReactNode, SetStateAction, useCallback, useState } from 'react'; +import React, { + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useState, + useContext, +} from 'react'; import { AppDependencies } from '../../../types'; import { Action, DataObject, ActionGroupItem, ExpandedRowMapInterface } from '../../types'; import { @@ -54,6 +61,8 @@ import { useDeleteConfirmState } from '../../utils/delete-confirm-modal-utils'; import { useContextMenuState } from '../../utils/context-menu'; import { generateResourceName } from '../../utils/resource-utils'; import { DocLinks } from '../../constants'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; +import { DataSourceContext } from '../../app-router'; export function renderBooleanToCheckMark(value: boolean): React.ReactNode { return value ? : ''; @@ -77,7 +86,7 @@ export function toggleRowDetails( }); } -export function renderRowExpanstionArrow( +export function renderRowExpansionArrow( itemIdToExpandedRowMap: ExpandedRowMapInterface, actionGroupDict: DataObject, setItemIdToExpandedRowMap: Dispatch> @@ -129,7 +138,7 @@ function getColumns( align: RIGHT_ALIGNMENT, width: '40px', isExpander: true, - render: renderRowExpanstionArrow( + render: renderRowExpansionArrow( itemIdToExpandedRowMap, actionGroupDict, setItemIdToExpandedRowMap @@ -182,6 +191,9 @@ export function PermissionList(props: AppDependencies) { const [selection, setSelection] = React.useState([]); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({}); + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; + const { dataSource, setDataSource } = useContext(DataSourceContext)!; + // Modal state const [editModal, setEditModal] = useState(null); @@ -194,7 +206,7 @@ export function PermissionList(props: AppDependencies) { const fetchData = useCallback(async () => { try { setLoading(true); - const actionGroups = await fetchActionGroups(props.coreStart.http); + const actionGroups = await fetchActionGroups(props.coreStart.http, dataSource.id); setActionGroupDict(actionGroups); setPermissionList(await mergeAllPermissions(actionGroups)); } catch (e) { @@ -203,16 +215,16 @@ export function PermissionList(props: AppDependencies) { } finally { setLoading(false); } - }, [props.coreStart.http]); + }, [props.coreStart.http, dataSource.id]); React.useEffect(() => { fetchData(); - }, [props.coreStart.http, fetchData]); + }, [props.coreStart.http, fetchData, dataSource.id]); const handleDelete = async () => { const groupsToDelete: string[] = selection.map((r) => r.name); try { - await requestDeleteActionGroups(props.coreStart.http, groupsToDelete); + await requestDeleteActionGroups(props.coreStart.http, groupsToDelete, dataSource.id); setPermissionList(difference(permissionList, selection)); setSelection([]); } catch (e) { @@ -276,14 +288,23 @@ export function PermissionList(props: AppDependencies) { handleClose={() => setEditModal(null)} handleSave={async (groupName, allowedAction) => { try { - await updateActionGroup(props.coreStart.http, groupName, { - allowed_actions: allowedAction, - }); + await updateActionGroup( + props.coreStart.http, + groupName, + { allowed_actions: allowedAction }, + dataSource.id + ); setEditModal(null); fetchData(); addToast({ id: 'saveSucceeded', - title: getSuccessToastMessage('Action group', action, groupName), + title: `${getSuccessToastMessage( + 'Action group', + action, + groupName, + dataSourceEnabled, + dataSource + )}`, color: 'success', }); } catch (e) { @@ -328,6 +349,12 @@ export function PermissionList(props: AppDependencies) { return ( <> +

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 965ccea01..0d72b6ea4 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 @@ -19,7 +19,7 @@ import { PermissionList, renderBooleanToCheckMark, toggleRowDetails, - renderRowExpanstionArrow, + renderRowExpansionArrow, } from '../permission-list'; import { EuiInMemoryTable, EuiButtonIcon } from '@elastic/eui'; import { @@ -49,6 +49,11 @@ jest.mock('../../../utils/toast-utils', () => ({ useToastState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]), })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); + describe('Permission list page ', () => { const sampleActionGroup: PermissionListingItem = { name: 'group', @@ -73,7 +78,7 @@ describe('Permission list page ', () => { describe('renderRowExpanstionArrow', () => { it('should render down arrow when collapsed', () => { - const renderFunc = renderRowExpanstionArrow({}, {}, jest.fn()); + const renderFunc = renderRowExpansionArrow({}, {}, jest.fn()); const Wrapper = () => <>{renderFunc(sampleActionGroup)}; const component = shallow(); @@ -81,7 +86,7 @@ describe('Permission list page ', () => { }); it('should render up arrow when expanded', () => { - const renderFunc = renderRowExpanstionArrow( + const renderFunc = renderRowExpansionArrow( { [sampleActionGroup.name]: sampleActionGroup }, {}, jest.fn() @@ -94,11 +99,14 @@ describe('Permission list page ', () => { }); describe('PermissionList', () => { + const mockCoreStart = { + http: 1, + }; it('render empty', () => { const component = shallow( @@ -111,14 +119,14 @@ describe('Permission list page ', () => { jest.spyOn(React, 'useEffect').mockImplementationOnce((f) => f()); shallow( ); - expect(fetchActionGroups).toBeCalled(); + expect(fetchActionGroups).toBeCalledWith(mockCoreStart.http, 'test'); }); it('fetch data error', () => { @@ -134,7 +142,7 @@ describe('Permission list page ', () => { shallow( @@ -147,8 +155,8 @@ describe('Permission list page ', () => { it('submit change', () => { const component = shallow( @@ -157,7 +165,12 @@ describe('Permission list page ', () => { const submitFunc = component.find(PermissionEditModal).prop('handleSave'); submitFunc('group1', []); - expect(updateActionGroup).toBeCalled(); + expect(updateActionGroup).toBeCalledWith( + mockCoreStart.http, + 'group1', + { allowed_actions: [] }, + 'test' + ); }); it('submit change error', () => { @@ -170,7 +183,7 @@ describe('Permission list page ', () => { const component = shallow( @@ -186,8 +199,8 @@ describe('Permission list page ', () => { it('delete action group', (done) => { shallow( @@ -197,7 +210,7 @@ describe('Permission list page ', () => { deleteFunc(); process.nextTick(() => { - expect(requestDeleteActionGroups).toBeCalled(); + expect(requestDeleteActionGroups).toBeCalledWith(mockCoreStart.http, [], 'test'); done(); }); }); @@ -207,7 +220,7 @@ describe('Permission list page ', () => { const component = shallow( diff --git a/public/apps/configuration/panels/role-edit/index-permission-panel.tsx b/public/apps/configuration/panels/role-edit/index-permission-panel.tsx index e52cb9128..a8a4284b6 100644 --- a/public/apps/configuration/panels/role-edit/index-permission-panel.tsx +++ b/public/apps/configuration/panels/role-edit/index-permission-panel.tsx @@ -23,7 +23,7 @@ import { EuiSuperSelect, EuiTextArea, } from '@elastic/eui'; -import React, { Dispatch, Fragment, SetStateAction } from 'react'; +import React, { Dispatch, Fragment, SetStateAction, useEffect } from 'react'; import { isEmpty } from 'lodash'; import { RoleIndexPermission } from '../../types'; import { ResourceType } from '../../../../../common'; @@ -320,9 +320,11 @@ export function IndexPermissionPanel(props: { }) { const { state, optionUniverse, setState } = props; // Show one empty row if there is no data. - if (isEmpty(state)) { - setState([getEmptyIndexPermission()]); - } + useEffect(() => { + if (isEmpty(state)) { + setState([getEmptyIndexPermission()]); + } + }, [state, setState]); return ( (true); + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; + const { dataSource, setDataSource } = useContext(DataSourceContext)!; + React.useEffect(() => { const action = props.action; if (action === 'edit' || action === 'duplicate') { const fetchData = async () => { try { - const roleData = await getRoleDetail(props.coreStart.http, props.sourceRoleName); + const roleData = await getRoleDetail( + props.coreStart.http, + props.sourceRoleName, + dataSource.id + ); setRoleClusterPermission(roleData.cluster_permissions.map(stringToComboBoxOption)); setRoleIndexPermission(buildIndexPermissionState(roleData.index_permissions)); setRoleTenantPermission(buildTenantPermissionState(roleData.tenant_permissions)); @@ -105,13 +114,13 @@ export function RoleEdit(props: RoleEditDeps) { fetchData(); } - }, [addToast, props.action, props.coreStart.http, props.sourceRoleName]); + }, [addToast, props.action, props.coreStart.http, props.sourceRoleName, dataSource.id]); const [actionGroups, setActionGroups] = useState>([]); React.useEffect(() => { const fetchActionGroupNames = async () => { try { - const actionGroupsObject = await fetchActionGroups(props.coreStart.http); + const actionGroupsObject = await fetchActionGroups(props.coreStart.http, dataSource.id); setActionGroups(Object.entries(actionGroupsObject)); } catch (e) { addToast(createUnknownErrorToast('actionGroup', 'load data')); @@ -120,13 +129,13 @@ export function RoleEdit(props: RoleEditDeps) { }; fetchActionGroupNames(); - }, [addToast, props.coreStart.http]); + }, [addToast, props.coreStart.http, dataSource.id]); const [tenantNames, setTenantNames] = React.useState([]); React.useEffect(() => { const fetchTenantNames = async () => { try { - setTenantNames(await fetchTenantNameList(props.coreStart.http)); + setTenantNames(await fetchTenantNameList(props.coreStart.http, dataSource.id)); } catch (e) { addToast(createUnknownErrorToast('tenant', 'load data')); console.error(e); @@ -134,7 +143,7 @@ export function RoleEdit(props: RoleEditDeps) { }; fetchTenantNames(); - }, [addToast, props.coreStart.http]); + }, [addToast, props.coreStart.http, dataSource.id]); const updateRoleHandler = async () => { try { @@ -146,16 +155,27 @@ export function RoleEdit(props: RoleEditDeps) { (v: RoleTenantPermissionStateClass) => !isEmpty(v.tenantPatterns) ); - await updateRole(props.coreStart.http, roleName, { - cluster_permissions: roleClusterPermission.map(comboBoxOptionToString), - index_permissions: unbuildIndexPermissionState(validIndexPermission), - tenant_permissions: unbuildTenantPermissionState(validTenantPermission), - }); + await updateRole( + props.coreStart.http, + roleName, + { + cluster_permissions: roleClusterPermission.map(comboBoxOptionToString), + index_permissions: unbuildIndexPermissionState(validIndexPermission), + tenant_permissions: unbuildTenantPermissionState(validTenantPermission), + }, + dataSource.id + ); setCrossPageToast(buildUrl(ResourceType.roles, Action.view, roleName), { id: 'updateRoleSucceeded', color: 'success', - title: getSuccessToastMessage('Role', props.action, roleName), + title: `${getSuccessToastMessage( + 'Role', + props.action, + roleName, + dataSourceEnabled, + dataSource + )}`, }); // Redirect to role view window.location.href = buildHashUrl(ResourceType.roles, Action.view, roleName); @@ -215,6 +235,12 @@ export function RoleEdit(props: RoleEditDeps) { return ( <> + {props.buildBreadcrumbs(TITLE_TEXT_DICT[props.action])} @@ -271,7 +297,12 @@ export function RoleEdit(props: RoleEditDeps) { - + {props.action === 'edit' ? 'Update' : 'Create'} diff --git a/public/apps/configuration/panels/role-edit/tenant-panel.tsx b/public/apps/configuration/panels/role-edit/tenant-panel.tsx index 2cce9e546..6183a27b7 100644 --- a/public/apps/configuration/panels/role-edit/tenant-panel.tsx +++ b/public/apps/configuration/panels/role-edit/tenant-panel.tsx @@ -14,7 +14,7 @@ */ import { EuiButton, EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiSuperSelect } from '@elastic/eui'; -import React, { Dispatch, Fragment, SetStateAction } from 'react'; +import React, { Dispatch, Fragment, SetStateAction, useEffect } from 'react'; import { isEmpty } from 'lodash'; import { RoleTenantPermission, TenantPermissionType, ComboBoxOptions } from '../../types'; import { @@ -129,9 +129,12 @@ export function TenantPanel(props: { }) { const { state, optionUniverse, setState } = props; // Show one empty row if there is no data. - if (isEmpty(state)) { - setState([getEmptyTenantPermission()]); - } + + useEffect(() => { + if (isEmpty(state)) { + setState([getEmptyTenantPermission()]); + } + }, [state, setState]); return ( { const state: RoleIndexPermissionStateClass[] = []; const optionUniverse: ComboBoxOptions = []; - shallow(); + render(); expect(setState).toHaveBeenCalledTimes(1); }); diff --git a/public/apps/configuration/panels/role-edit/test/role-edit-filtering.test.tsx b/public/apps/configuration/panels/role-edit/test/role-edit-filtering.test.tsx index 68a521ab6..36d67fdc9 100644 --- a/public/apps/configuration/panels/role-edit/test/role-edit-filtering.test.tsx +++ b/public/apps/configuration/panels/role-edit/test/role-edit-filtering.test.tsx @@ -33,6 +33,7 @@ jest.mock('../../../utils/role-detail-utils', () => ({ }), updateRole: jest.fn(), })); + jest.mock('../../../utils/action-groups-utils'); jest.mock('../cluster-permission-panel', () => ({ @@ -43,6 +44,11 @@ jest.mock('../index-permission-panel', () => ({ IndexPermissionPanel: jest.fn(() => null) as jest.Mock, })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); + describe('Role edit filtering', () => { const sampleSourceRole = 'role'; const mockCoreStart = { @@ -94,7 +100,7 @@ describe('Role edit filtering', () => { sourceRoleName={sampleSourceRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -150,7 +156,7 @@ describe('Role edit filtering', () => { sourceRoleName={sampleSourceRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> diff --git a/public/apps/configuration/panels/role-edit/test/role-edit.test.tsx b/public/apps/configuration/panels/role-edit/test/role-edit.test.tsx index 45eefff51..6e0b61237 100644 --- a/public/apps/configuration/panels/role-edit/test/role-edit.test.tsx +++ b/public/apps/configuration/panels/role-edit/test/role-edit.test.tsx @@ -38,6 +38,10 @@ jest.mock('../../../utils/action-groups-utils', () => ({ })); jest.mock('../../../utils/tenant-utils'); jest.mock('../../../utils/storage-utils'); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); describe('Role edit', () => { const sampleSourceRole = 'role'; @@ -59,7 +63,7 @@ describe('Role edit', () => { sourceRoleName={sampleSourceRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -85,7 +89,7 @@ describe('Role edit', () => { sourceRoleName={sampleSourceRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -108,7 +112,7 @@ describe('Role edit', () => { sourceRoleName={sampleSourceRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -116,11 +120,16 @@ describe('Role edit', () => { // click update component.find(EuiButton).last().simulate('click'); - expect(updateRole).toBeCalledWith(mockCoreStart.http, '', { - cluster_permissions: [], - index_permissions: [], - tenant_permissions: [], - }); + expect(updateRole).toBeCalledWith( + mockCoreStart.http, + '', + { + cluster_permissions: [], + index_permissions: [], + tenant_permissions: [], + }, + 'test' + ); process.nextTick(() => { expect(setCrossPageToast).toHaveBeenCalled(); diff --git a/public/apps/configuration/panels/role-edit/test/tenant-panel.test.tsx b/public/apps/configuration/panels/role-edit/test/tenant-panel.test.tsx index 6fd5adcdc..d7ad8f36c 100644 --- a/public/apps/configuration/panels/role-edit/test/tenant-panel.test.tsx +++ b/public/apps/configuration/panels/role-edit/test/tenant-panel.test.tsx @@ -24,6 +24,7 @@ import { import { shallow } from 'enzyme'; import React from 'react'; import { EuiComboBox, EuiButton, EuiSuperSelect } from '@elastic/eui'; +import { render } from '@testing-library/react'; jest.mock('../../../utils/array-state-utils'); // eslint-disable-next-line @@ -76,7 +77,7 @@ describe('Role edit - tenant panel', () => { const setState = jest.fn(); it('render an empty row if data is empty', () => { - shallow(); + render(); expect(setState).toHaveBeenCalledWith([ { diff --git a/public/apps/configuration/panels/role-list.tsx b/public/apps/configuration/panels/role-list.tsx index c9ecab34b..7bdf66593 100644 --- a/public/apps/configuration/panels/role-list.tsx +++ b/public/apps/configuration/panels/role-list.tsx @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; import { EuiFlexGroup, EuiText, @@ -54,6 +54,8 @@ import { showTableStatusMessage } from '../utils/loading-spinner-utils'; import { useDeleteConfirmState } from '../utils/delete-confirm-modal-utils'; import { useContextMenuState } from '../utils/context-menu'; import { DocLinks } from '../constants'; +import { DataSourceContext } from '../app-router'; +import { SecurityPluginTopNavMenu } from '../top-nav-menu'; const columns: Array> = [ { @@ -105,13 +107,14 @@ export function RoleList(props: AppDependencies) { const [errorFlag, setErrorFlag] = React.useState(false); const [selection, setSelection] = React.useState([]); const [loading, setLoading] = useState(false); + const { dataSource, setDataSource } = useContext(DataSourceContext)!; React.useEffect(() => { const fetchData = async () => { try { setLoading(true); - const rawRoleData = await fetchRole(props.coreStart.http); - const rawRoleMappingData = await fetchRoleMapping(props.coreStart.http); + const rawRoleData = await fetchRole(props.coreStart.http, dataSource.id); + const rawRoleMappingData = await fetchRoleMapping(props.coreStart.http, dataSource.id); const processedData = transformRoleData(rawRoleData, rawRoleMappingData); setRoleData(processedData); } catch (e) { @@ -123,12 +126,12 @@ export function RoleList(props: AppDependencies) { }; fetchData(); - }, [props.coreStart.http]); + }, [props.coreStart.http, dataSource.id]); const handleDelete = async () => { const rolesToDelete: string[] = selection.map((r) => r.roleName); try { - await requestDeleteRoles(props.coreStart.http, rolesToDelete); + await requestDeleteRoles(props.coreStart.http, rolesToDelete, dataSource.id); // Refresh from server (calling fetchData) does not work here, the server still return the roles // that had been just deleted, probably because ES takes some time to sync to all nodes. // So here remove the selected roles from local memory directly. @@ -250,6 +253,12 @@ export function RoleList(props: AppDependencies) { return ( <> +

Roles

@@ -278,7 +287,11 @@ export function RoleList(props: AppDependencies) { {actionsMenu} - + Create role 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 ef9819b17..8bfc51b01 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 @@ -23,7 +23,7 @@ import { EuiTitle, EuiGlobalToastList, } from '@elastic/eui'; -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import { BreadcrumbsPageDependencies } from '../../../types'; import { InternalUsersPanel } from './users-panel'; import { @@ -43,6 +43,9 @@ import { createErrorToast, createUnknownErrorToast, useToastState } from '../../ import { DocLinks } from '../../constants'; import { setCrossPageToast } from '../../utils/storage-utils'; import { ExternalLink } from '../../utils/display-utils'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; +import { DataSourceContext } from '../../app-router'; +import { getClusterInfo } from '../../../../utils/datasource-utils'; interface RoleEditMappedUserProps extends BreadcrumbsPageDependencies { roleName: string; @@ -60,13 +63,16 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { const [userNames, setUserNames] = useState([]); const [hosts, setHosts] = React.useState([]); const [toasts, addToast, removeToast] = useToastState(); + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; + const { dataSource, setDataSource } = useContext(DataSourceContext)!; React.useEffect(() => { const fetchData = async () => { try { const originalRoleMapData: RoleMappingDetail | undefined = await getRoleMappingData( props.coreStart.http, - props.roleName + props.roleName, + dataSource.id ); if (originalRoleMapData) { setInternalUsers(originalRoleMapData.users.map(stringToComboBoxOption)); @@ -80,12 +86,14 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { }; fetchData(); - }, [addToast, props.coreStart.http, props.roleName]); + }, [addToast, props.coreStart.http, props.roleName, dataSource.id]); React.useEffect(() => { const fetchInternalUserNames = async () => { try { - setUserNames(await fetchUserNameList(props.coreStart.http)); + setUserNames( + await fetchUserNameList(props.coreStart.http, ResourceType.users, dataSource.id) + ); } catch (e) { addToast(createUnknownErrorToast('fetchInternalUserNames', 'load data')); console.error(e); @@ -93,7 +101,7 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { }; fetchInternalUserNames(); - }, [addToast, props.coreStart.http]); + }, [addToast, props.coreStart.http, dataSource.id]); const internalUserOptions = userNames.map(stringToComboBoxOption); const updateRoleMappingHandler = async () => { @@ -108,13 +116,16 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { hosts, }; - await updateRoleMapping(props.coreStart.http, props.roleName, updateObject); + await updateRoleMapping(props.coreStart.http, props.roleName, updateObject, dataSource.id); setCrossPageToast( buildUrl(ResourceType.roles, Action.view, props.roleName, SubAction.mapuser), { id: 'updateRoleMappingSucceeded', color: 'success', - title: 'Role "' + props.roleName + '" successfully updated.', + title: `Role "${props.roleName}" successfully updated ${getClusterInfo( + dataSourceEnabled, + dataSource + )}`, } ); window.location.href = buildHashUrl( @@ -135,6 +146,12 @@ export function RoleEditMappedUser(props: RoleEditMappedUserProps) { return ( <> + {props.buildBreadcrumbs(props.roleName, TITLE_TEXT_DICT[SubAction.mapuser])} diff --git a/public/apps/configuration/panels/role-mapping/test/role-edit-mapped-user.test.tsx b/public/apps/configuration/panels/role-mapping/test/role-edit-mapped-user.test.tsx index 3cf1b3c73..44d4d0a2f 100644 --- a/public/apps/configuration/panels/role-mapping/test/role-edit-mapped-user.test.tsx +++ b/public/apps/configuration/panels/role-mapping/test/role-edit-mapped-user.test.tsx @@ -26,6 +26,10 @@ jest.mock('../../../utils/role-mapping-utils'); jest.mock('../../../utils/internal-user-list-utils', () => ({ fetchUserNameList: jest.fn().mockReturnValue([]), })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); // eslint-disable-next-line const roleMappingUtils = require('../../../utils/role-mapping-utils'); @@ -52,7 +56,7 @@ describe('Role mapping edit', () => { roleName={sampleRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -77,7 +81,7 @@ describe('Role mapping edit', () => { roleName={sampleRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -98,7 +102,7 @@ describe('Role mapping edit', () => { roleName={sampleRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -106,11 +110,16 @@ describe('Role mapping edit', () => { // click update component.find('#map').last().simulate('click'); - expect(updateRoleMapping).toBeCalledWith(mockCoreStart.http, sampleRole, { - users: [], - backend_roles: [], - hosts: [], - }); + expect(updateRoleMapping).toBeCalledWith( + mockCoreStart.http, + sampleRole, + { + users: [], + backend_roles: [], + hosts: [], + }, + 'test' + ); }); it('submit update error', () => { @@ -126,7 +135,7 @@ describe('Role mapping edit', () => { roleName={sampleRole} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> diff --git a/public/apps/configuration/panels/role-view/role-view.tsx b/public/apps/configuration/panels/role-view/role-view.tsx index 9df9ad199..caac1bfd7 100644 --- a/public/apps/configuration/panels/role-view/role-view.tsx +++ b/public/apps/configuration/panels/role-view/role-view.tsx @@ -13,8 +13,7 @@ * permissions and limitations under the License. */ -import React, { useState } from 'react'; - +import React, { useState, useContext } from 'react'; import { EuiButton, EuiPageContentHeader, @@ -70,6 +69,9 @@ import { showTableStatusMessage } from '../../utils/loading-spinner-utils'; import { useContextMenuState } from '../../utils/context-menu'; import { requestDeleteRoles } from '../../utils/role-list-utils'; import { setCrossPageToast } from '../../utils/storage-utils'; +import { DataSourceContext } from '../../app-router'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; +import { getClusterInfo } from '../../../../utils/datasource-utils'; interface RoleViewProps extends BreadcrumbsPageDependencies { roleName: string; @@ -108,6 +110,8 @@ export function RoleView(props: RoleViewProps) { const [toasts, addToast, removeToast] = useToastState(); const [isReserved, setIsReserved] = React.useState(false); const [loading, setLoading] = React.useState(false); + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; + const { dataSource, setDataSource } = useContext(DataSourceContext)!; const PERMISSIONS_TAB_INDEX = 0; const MAP_USER_TAB_INDEX = 1; @@ -116,15 +120,19 @@ export function RoleView(props: RoleViewProps) { const fetchData = async () => { try { setLoading(true); - const originalRoleMapData = await getRoleMappingData(props.coreStart.http, props.roleName); + const originalRoleMapData = await getRoleMappingData( + props.coreStart.http, + props.roleName, + dataSource.id + ); if (originalRoleMapData) { setMappedUsers(transformRoleMappingData(originalRoleMapData)); setHosts(originalRoleMapData.hosts); } - const actionGroups = await fetchActionGroups(props.coreStart.http); + const actionGroups = await fetchActionGroups(props.coreStart.http, dataSource.id); setActionGroupDict(actionGroups); - const roleData = await getRoleDetail(props.coreStart.http, props.roleName); + const roleData = await getRoleDetail(props.coreStart.http, props.roleName, dataSource.id); setIsReserved(roleData.reserved); setRoleClusterPermission(roleData.cluster_permissions); setRoleIndexPermission(transformRoleIndexPermissions(roleData.index_permissions)); @@ -139,7 +147,7 @@ export function RoleView(props: RoleViewProps) { }; fetchData(); - }, [addToast, props.coreStart.http, props.roleName, props.prevAction]); + }, [addToast, props.coreStart.http, props.roleName, props.prevAction, dataSource.id]); const handleRoleMappingDelete = async () => { try { @@ -155,7 +163,7 @@ export function RoleView(props: RoleViewProps) { backend_roles: difference(externalIdentities, usersToDelete), hosts, }; - await updateRoleMapping(props.coreStart.http, props.roleName, updateObject); + await updateRoleMapping(props.coreStart.http, props.roleName, updateObject, dataSource.id); setMappedUsers(difference(mappedUsers, selection)); setSelection([]); @@ -263,6 +271,7 @@ export function RoleView(props: RoleViewProps) { coreStart={props.coreStart} loading={loading} isReserved={isReserved} + dataSourceId={dataSource.id} /> ), @@ -350,11 +359,11 @@ export function RoleView(props: RoleViewProps) { color="danger" onClick={async () => { try { - await requestDeleteRoles(props.coreStart.http, [props.roleName]); + await requestDeleteRoles(props.coreStart.http, [props.roleName], dataSource.id); setCrossPageToast(buildUrl(ResourceType.roles), { id: 'deleteRole', color: 'success', - title: props.roleName + ' deleted.', + title: `${props.roleName} deleted ${getClusterInfo(dataSourceEnabled, dataSource)}`, }); window.location.href = buildHashUrl(ResourceType.roles); } catch (e) { @@ -384,6 +393,12 @@ export function RoleView(props: RoleViewProps) { return ( <> + {props.buildBreadcrumbs(props.roleName)} diff --git a/public/apps/configuration/panels/role-view/tenants-panel.tsx b/public/apps/configuration/panels/role-view/tenants-panel.tsx index d0cde8cd9..2e5094028 100644 --- a/public/apps/configuration/panels/role-view/tenants-panel.tsx +++ b/public/apps/configuration/panels/role-view/tenants-panel.tsx @@ -49,6 +49,7 @@ interface RoleViewTenantsPanelProps { coreStart: CoreStart; loading: boolean; isReserved: boolean; + dataSourceId: string; } export function TenantsPanel(props: RoleViewTenantsPanelProps) { @@ -62,7 +63,7 @@ export function TenantsPanel(props: RoleViewTenantsPanelProps) { React.useEffect(() => { const fetchData = async () => { try { - const rawTenantData = await fetchTenants(props.coreStart.http); + const rawTenantData = await fetchTenants(props.coreStart.http, props.dataSourceId); const processedTenantData = transformTenantData(rawTenantData); setTenantPermissionDetail( transformRoleTenantPermissionData(props.tenantPermissions, processedTenantData) diff --git a/public/apps/configuration/panels/role-view/test/__snapshots__/role-view.test.tsx.snap b/public/apps/configuration/panels/role-view/test/__snapshots__/role-view.test.tsx.snap index 6b65e56f7..833c64f6f 100644 --- a/public/apps/configuration/panels/role-view/test/__snapshots__/role-view.test.tsx.snap +++ b/public/apps/configuration/panels/role-view/test/__snapshots__/role-view.test.tsx.snap @@ -2,6 +2,40 @@ exports[`Role view basic rendering when permission tab is selected 1`] = ` + + ({ createUnknownErrorToast: jest.fn(), useToastState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]), })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); describe('Role view', () => { const setState = jest.fn(); @@ -96,7 +100,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -119,7 +123,7 @@ describe('Role view', () => { prevAction={SubAction.mapuser} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -134,7 +138,7 @@ describe('Role view', () => { prevAction={SubAction.mapuser} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -155,7 +159,7 @@ describe('Role view', () => { prevAction={SubAction.mapuser} buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -174,7 +178,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -203,7 +207,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -219,7 +223,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -245,7 +249,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -267,7 +271,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> @@ -287,7 +291,7 @@ describe('Role view', () => { prevAction="" buildBreadcrumbs={buildBreadcrumbs} coreStart={mockCoreStart as any} - navigation={{} as any} + depsStart={{} as any} params={{} as any} config={{} as any} /> diff --git a/public/apps/configuration/panels/service-account-list.tsx b/public/apps/configuration/panels/service-account-list.tsx index 5af46389d..4ed2d7a5f 100644 --- a/public/apps/configuration/panels/service-account-list.tsx +++ b/public/apps/configuration/panels/service-account-list.tsx @@ -35,13 +35,15 @@ import { getAuthInfo } from '../../../utils/auth-info-utils'; import { AppDependencies } from '../../types'; import { API_ENDPOINT_SERVICEACCOUNTS, DocLinks } from '../constants'; import { Action } from '../types'; -import { ResourceType } from '../../../../common'; +import { LOCAL_CLUSTER_ID, ResourceType } from '../../../../common'; import { EMPTY_FIELD_VALUE } from '../ui-constants'; import { useContextMenuState } from '../utils/context-menu'; import { ExternalLink, tableItemsUIProps, truncatedListView } from '../utils/display-utils'; import { getUserList, InternalUsersListing } from '../utils/internal-user-list-utils'; import { showTableStatusMessage } from '../utils/loading-spinner-utils'; import { buildHashUrl } from '../utils/url-builder'; +import { LocalCluster } from '../app-router'; +import { SecurityPluginTopNavMenu } from '../top-nav-menu'; export function dictView(items: Dictionary) { if (isEmpty(items)) { @@ -102,7 +104,11 @@ export function ServiceAccountList(props: AppDependencies) { const fetchData = async () => { try { setLoading(true); - const userDataPromise = getUserList(props.coreStart.http, ResourceType.serviceAccounts); + const userDataPromise = getUserList( + props.coreStart.http, + ResourceType.serviceAccounts, + LOCAL_CLUSTER_ID + ); setCurrentUsername((await getAuthInfo(props.coreStart.http)).user_name); setUserData(await userDataPromise); } catch (e) { @@ -159,6 +165,12 @@ export function ServiceAccountList(props: AppDependencies) { return ( <> + {}} + selectedDataSource={LocalCluster} + />

Service accounts

diff --git a/public/apps/configuration/panels/tenant-list/configure_tab1.tsx b/public/apps/configuration/panels/tenant-list/configure_tab1.tsx index dafd153d6..2430ce1ac 100644 --- a/public/apps/configuration/panels/tenant-list/configure_tab1.tsx +++ b/public/apps/configuration/panels/tenant-list/configure_tab1.tsx @@ -49,6 +49,7 @@ import { useToastState, } from '../../utils/toast-utils'; import { getDashboardsInfo } from '../../../../utils/dashboards-info-utils'; +import { LOCAL_CLUSTER_ID } from '../../../../../common'; export function ConfigureTab1(props: AppDependencies) { const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(false); @@ -191,7 +192,7 @@ export function ConfigureTab1(props: AppDependencies) { default_tenant: (await getDashboardsInfo(props.coreStart.http)).default_tenant, }); - const rawTenantData = await fetchTenants(props.coreStart.http); + const rawTenantData = await fetchTenants(props.coreStart.http, LOCAL_CLUSTER_ID); const processedTenantData = transformTenantData(rawTenantData); setTenantData(processedTenantData); } catch (e) { diff --git a/public/apps/configuration/panels/tenant-list/manage_tab.tsx b/public/apps/configuration/panels/tenant-list/manage_tab.tsx index bc36114b4..79bcbe908 100644 --- a/public/apps/configuration/panels/tenant-list/manage_tab.tsx +++ b/public/apps/configuration/panels/tenant-list/manage_tab.tsx @@ -42,7 +42,7 @@ import { flow } from 'lodash'; import { getCurrentUser } from '../../../../utils/auth-info-utils'; import { AppDependencies } from '../../../types'; import { Action, Tenant } from '../../types'; -import { ResourceType } from '../../../../../common'; +import { LOCAL_CLUSTER_ID, ResourceType } from '../../../../../common'; import { ExternalLink, renderCustomization, tableItemsUIProps } from '../../utils/display-utils'; import { fetchTenants, @@ -68,7 +68,7 @@ import { useContextMenuState } from '../../utils/context-menu'; import { generateResourceName } from '../../utils/resource-utils'; import { DocLinks } from '../../constants'; import { TenantList } from './tenant-list'; -import { getBreadcrumbs } from '../../app-router'; +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'; @@ -90,13 +90,14 @@ export function ManageTab(props: AppDependencies) { const [isMultiTenancyEnabled, setIsMultiTenancyEnabled] = useState(false); const [isPrivateTenantEnabled, setIsPrivateTenantEnabled] = useState(false); const [dashboardsDefaultTenant, setDashboardsDefaultTenant] = useState(''); + const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled; const { http } = props.coreStart; const fetchData = useCallback(async () => { try { setLoading(true); - const rawTenantData = await fetchTenants(http); + const rawTenantData = await fetchTenants(http, LOCAL_CLUSTER_ID); const processedTenantData = transformTenantData(rawTenantData); const activeTenant = await fetchCurrentTenant(http); const currentUser = await getCurrentUser(http); @@ -465,7 +466,13 @@ export function ManageTab(props: AppDependencies) { fetchData(); addToast({ id: 'saveSucceeded', - title: getSuccessToastMessage('Tenant', action, tenantName), + title: getSuccessToastMessage( + 'Tenant', + action, + tenantName, + dataSourceEnabled, + LocalCluster + ), color: 'success', }); } catch (e) { diff --git a/public/apps/configuration/panels/tenant-list/tenant-list.tsx b/public/apps/configuration/panels/tenant-list/tenant-list.tsx index 6a4f53382..0b97c6ae1 100644 --- a/public/apps/configuration/panels/tenant-list/tenant-list.tsx +++ b/public/apps/configuration/panels/tenant-list/tenant-list.tsx @@ -31,6 +31,8 @@ import { ExternalLink } from '../../utils/display-utils'; import { DocLinks } from '../../constants'; import { getDashboardsInfo } from '../../../../utils/dashboards-info-utils'; import { TenantInstructionView } from './tenant-instruction-view'; +import { LocalCluster } from '../../app-router'; +import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; interface TenantListProps extends AppDependencies { tabID: string; @@ -133,6 +135,12 @@ export function TenantList(props: TenantListProps) { return ( <> + {}} + selectedDataSource={LocalCluster} + />

Dashboards multi-tenancy

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 b92dd8806..a00a417d8 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 @@ -98,7 +98,7 @@ describe('Tenant list', () => { const component = shallow( @@ -126,7 +126,7 @@ describe('Tenant list', () => { const component = shallow( @@ -145,7 +145,7 @@ describe('Tenant list', () => { shallow( @@ -175,7 +175,7 @@ describe('Tenant list', () => { shallow( @@ -195,7 +195,7 @@ describe('Tenant list', () => { shallow( @@ -220,7 +220,7 @@ describe('Tenant list', () => { shallow( @@ -239,7 +239,7 @@ describe('Tenant list', () => { const component = shallow( @@ -261,7 +261,7 @@ describe('Tenant list', () => { const component = shallow( @@ -299,7 +299,7 @@ describe('Tenant list', () => { const component = shallow( @@ -315,7 +315,7 @@ describe('Tenant list', () => { const component = shallow( @@ -335,7 +335,7 @@ describe('Tenant list', () => { const component = shallow( @@ -364,7 +364,7 @@ describe('Tenant list', () => { component = shallow( diff --git a/public/apps/configuration/panels/test/__snapshots__/get-started.test.tsx.snap b/public/apps/configuration/panels/test/__snapshots__/get-started.test.tsx.snap index 82c3ec0b8..0b2126950 100644 --- a/public/apps/configuration/panels/test/__snapshots__/get-started.test.tsx.snap +++ b/public/apps/configuration/panels/test/__snapshots__/get-started.test.tsx.snap @@ -5,6 +5,29 @@ exports[`Get started (landing page) renders when backend configuration is disabl
+ + Reserved diff --git a/public/apps/configuration/panels/test/get-started.test.tsx b/public/apps/configuration/panels/test/get-started.test.tsx index fdd09500a..0823cdbb9 100644 --- a/public/apps/configuration/panels/test/get-started.test.tsx +++ b/public/apps/configuration/panels/test/get-started.test.tsx @@ -22,6 +22,7 @@ import { buildHashUrl } from '../../utils/url-builder'; import { GetStarted } from '../get-started'; import * as ToastUtils from '../../utils/toast-utils'; // Import all functions from toast-utils import * as RequestUtils from '../../utils/request-utils'; // Import all functions from request-utils +import { RequestContext } from '../../utils/request-utils'; jest.mock('../../utils/toast-utils', () => ({ createSuccessToast: jest.fn(), @@ -29,8 +30,9 @@ jest.mock('../../utils/toast-utils', () => ({ useToastState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]), })); -jest.mock('../../utils/request-utils', () => ({ - httpDelete: jest.fn(), +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function })); describe('Get started (landing page)', () => { @@ -47,9 +49,9 @@ describe('Get started (landing page)', () => { const component = shallow( ); expect(component).toMatchSnapshot(); @@ -64,9 +66,9 @@ describe('Get started (landing page)', () => { const component = shallow( ); expect(component).toMatchSnapshot(); @@ -78,9 +80,9 @@ describe('Get started (landing page)', () => { wrapper = shallow( ); jest.clearAllMocks(); @@ -140,9 +142,9 @@ describe('Get started (landing page)', () => { wrapper = shallow( ); jest.clearAllMocks(); @@ -153,8 +155,10 @@ describe('Get started (landing page)', () => { expect(button).toHaveLength(1); // Failure case: Mock httpDelete to reject + // Success case: Mock httpDelete to resolve + const mockRequestContext = new RequestContext('dummyDataSourceId'); jest - .spyOn(RequestUtils, 'httpDelete') + .spyOn(mockRequestContext, 'httpDelete') .mockRejectedValueOnce(new Error('Failed to purge cache')); await button.props().onClick(); // Simulate button click @@ -166,7 +170,14 @@ describe('Get started (landing page)', () => { expect(button).toHaveLength(1); // Success case: Mock httpDelete to resolve - jest.spyOn(RequestUtils, 'httpDelete').mockResolvedValueOnce('nice'); + const mockRequestContext = new RequestContext('dummyDataSourceId'); + jest.spyOn(mockRequestContext, 'httpDelete').mockResolvedValueOnce('nice'); + + // Mock the createRequestContextWithDataSourceId function to return the mock instance + jest + .spyOn(RequestUtils, 'createRequestContextWithDataSourceId') + .mockReturnValue(mockRequestContext); + await button.props().onClick(); // Simulate button click expect(ToastUtils.createSuccessToast).toHaveBeenCalledTimes(1); }); diff --git a/public/apps/configuration/panels/test/role-list.test.tsx b/public/apps/configuration/panels/test/role-list.test.tsx index a70dac666..becbfe806 100644 --- a/public/apps/configuration/panels/test/role-list.test.tsx +++ b/public/apps/configuration/panels/test/role-list.test.tsx @@ -36,6 +36,11 @@ jest.mock('../../utils/context-menu', () => ({ jest.mock('../../utils/delete-confirm-modal-utils', () => ({ useDeleteConfirmState: jest.fn().mockReturnValue([jest.fn(), '']), })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); + // eslint-disable-next-line const mockRoleListUtils = require('../../utils/role-list-utils'); diff --git a/public/apps/configuration/panels/test/user-list.test.tsx b/public/apps/configuration/panels/test/user-list.test.tsx index 32455fa53..2e96573f5 100644 --- a/public/apps/configuration/panels/test/user-list.test.tsx +++ b/public/apps/configuration/panels/test/user-list.test.tsx @@ -37,6 +37,10 @@ jest.mock('../../utils/context-menu', () => ({ .fn() .mockImplementation((buttonText, buttonProps, children) => [children, jest.fn()]), })); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn().mockReturnValue({ dataSource: { id: 'test' }, setDataSource: jest.fn() }), // Mock the useContext hook to return dummy datasource and setdatasource function +})); import { getAuthInfo } from '../../../../utils/auth-info-utils'; import { buildHashUrl } from '../../utils/url-builder'; diff --git a/public/apps/configuration/panels/user-list.tsx b/public/apps/configuration/panels/user-list.tsx index b75f62283..0d8c20015 100644 --- a/public/apps/configuration/panels/user-list.tsx +++ b/public/apps/configuration/panels/user-list.tsx @@ -31,7 +31,7 @@ import { Query, } from '@elastic/eui'; import { Dictionary, difference, isEmpty, map } from 'lodash'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { getAuthInfo } from '../../../utils/auth-info-utils'; import { AppDependencies } from '../../types'; import { API_ENDPOINT_INTERNALUSERS, DocLinks } from '../constants'; @@ -48,6 +48,8 @@ import { } from '../utils/internal-user-list-utils'; import { showTableStatusMessage } from '../utils/loading-spinner-utils'; import { buildHashUrl } from '../utils/url-builder'; +import { DataSourceContext } from '../app-router'; +import { SecurityPluginTopNavMenu } from '../top-nav-menu'; export function dictView(items: Dictionary) { if (isEmpty(items)) { @@ -103,12 +105,17 @@ export function UserList(props: AppDependencies) { const [currentUsername, setCurrentUsername] = useState(''); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(null); + const { dataSource, setDataSource } = useContext(DataSourceContext)!; React.useEffect(() => { const fetchData = async () => { try { setLoading(true); - const userDataPromise = getUserList(props.coreStart.http, ResourceType.users); + const userDataPromise = getUserList( + props.coreStart.http, + ResourceType.users, + dataSource.id + ); setCurrentUsername((await getAuthInfo(props.coreStart.http)).user_name); setUserData(await userDataPromise); } catch (e) { @@ -120,12 +127,12 @@ export function UserList(props: AppDependencies) { }; fetchData(); - }, [props.coreStart.http]); + }, [props.coreStart.http, dataSource.id]); const handleDelete = async () => { const usersToDelete: string[] = selection.map((r) => r.username); try { - await requestDeleteUsers(props.coreStart.http, usersToDelete); + await requestDeleteUsers(props.coreStart.http, usersToDelete, dataSource.id); // Refresh from server (calling fetchData) does not work here, the server still return the users // that had been just deleted, probably because ES takes some time to sync to all nodes. // So here remove the selected users from local memory directly. @@ -194,6 +201,12 @@ export function UserList(props: AppDependencies) { return ( <> +

Internal users

@@ -224,7 +237,11 @@ export function UserList(props: AppDependencies) { {actionsMenu} - + Create user account diff --git a/public/apps/configuration/test/__snapshots__/app-router.test.tsx.snap b/public/apps/configuration/test/__snapshots__/app-router.test.tsx.snap new file mode 100644 index 000000000..14c55f183 --- /dev/null +++ b/public/apps/configuration/test/__snapshots__/app-router.test.tsx.snap @@ -0,0 +1,639 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SecurityPluginTopNavMenu renders DataSourceMenu when dataSource is enabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/public/apps/configuration/test/app-router.test.tsx b/public/apps/configuration/test/app-router.test.tsx new file mode 100644 index 000000000..f93c1014d --- /dev/null +++ b/public/apps/configuration/test/app-router.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; +import { AppRouter } from '../app-router'; + +describe('SecurityPluginTopNavMenu', () => { + const coreStartMock = { + savedObjects: { + client: jest.fn(), + }, + notifications: jest.fn(), + chrome: { + setBreadcrumbs: jest.fn(), + }, + }; + + const dataSourceMenuMock = jest.fn(() =>
Mock DataSourceMenu
); + + const dataSourceManagementMock = { + ui: { + DataSourceMenu: dataSourceMenuMock, + }, + }; + + it('renders DataSourceMenu when dataSource is enabled', () => { + const securityPluginStartDepsMock = { + dataSource: { + dataSourceEnabled: true, + }, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/apps/configuration/test/top-nav-menu.test.tsx b/public/apps/configuration/test/top-nav-menu.test.tsx new file mode 100644 index 000000000..2e2872f65 --- /dev/null +++ b/public/apps/configuration/test/top-nav-menu.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 React from 'react'; +import { render } from 'enzyme'; +import { SecurityPluginTopNavMenu } from '../top-nav-menu'; + +describe('SecurityPluginTopNavMenu', () => { + const coreStartMock = { + savedObjects: { + client: jest.fn(), + }, + notifications: jest.fn(), + }; + + const dataSourceMenuMock = jest.fn(() =>
Mock DataSourceMenu
); + + const dataSourceManagementMock = { + ui: { + getDataSourceMenu: jest.fn().mockReturnValue(dataSourceMenuMock), + }, + }; + + it('renders DataSourceMenu when dataSource is enabled', () => { + const securityPluginStartDepsMock = { + dataSource: { + dataSourceEnabled: true, + }, + }; + + const wrapper = render( + + ); + + expect(dataSourceMenuMock).toBeCalled(); + expect(wrapper.html()).not.toBe(''); + }); + + it('renders null when dataSource is disabled', () => { + const securityPluginStartDepsMock = { + dataSource: { + dataSourceEnabled: false, + }, + }; + + const wrapper = render( + {}} + params={{}} + /> + ); + + expect(dataSourceMenuMock).not.toBeCalled(); + expect(wrapper.html()).toBe(''); + }); +}); diff --git a/public/apps/configuration/top-nav-menu.tsx b/public/apps/configuration/top-nav-menu.tsx new file mode 100644 index 000000000..669a6d805 --- /dev/null +++ b/public/apps/configuration/top-nav-menu.tsx @@ -0,0 +1,67 @@ +/* + * 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 React from 'react'; +import { DataSourceSelectableConfig } from 'src/plugins/data_source_management/public'; +import { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types'; +import { AppDependencies } from '../types'; +import { setDataSourceInUrl } from '../../utils/datasource-utils'; + +export interface TopNavMenuProps extends AppDependencies { + dataSourcePickerReadOnly: boolean; + setDataSource: React.Dispatch>; + selectedDataSource: DataSourceOption; +} + +export const SecurityPluginTopNavMenu = React.memo( + (props: TopNavMenuProps) => { + const { + coreStart, + depsStart, + params, + dataSourceManagement, + setDataSource, + selectedDataSource, + dataSourcePickerReadOnly, + } = props; + const { setHeaderActionMenu } = params; + const DataSourceMenu = dataSourceManagement?.ui.getDataSourceMenu(); + + const dataSourceEnabled = !!depsStart.dataSource?.dataSourceEnabled; + + const wrapSetDataSourceWithUpdateUrl = (dataSources: DataSourceOption[]) => { + setDataSourceInUrl(dataSources[0]); + setDataSource(dataSources[0]); + }; + + return dataSourceEnabled ? ( + + ) : null; + }, + (prevProps, newProps) => + prevProps.selectedDataSource.id === newProps.selectedDataSource.id && + prevProps.dataSourcePickerReadOnly === newProps.dataSourcePickerReadOnly +); diff --git a/public/apps/configuration/utils/action-groups-utils.tsx b/public/apps/configuration/utils/action-groups-utils.tsx index 7d565b01f..780bee9ec 100644 --- a/public/apps/configuration/utils/action-groups-utils.tsx +++ b/public/apps/configuration/utils/action-groups-utils.tsx @@ -17,7 +17,7 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { map } from 'lodash'; import { API_ENDPOINT_ACTIONGROUPS, CLUSTER_PERMISSIONS, INDEX_PERMISSIONS } from '../constants'; import { DataObject, ActionGroupItem, ActionGroupUpdate, ObjectsMessage } from '../types'; -import { httpDelete, httpGet, httpPost } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; import { getResourceUrl } from './resource-utils'; export interface PermissionListingItem { @@ -29,11 +29,16 @@ export interface PermissionListingItem { hasIndexPermission: boolean; } -export async function fetchActionGroups(http: HttpStart): Promise> { - const actiongroups = await httpGet>( +export async function fetchActionGroups( + http: HttpStart, + dataSourceId: string +): Promise> { + const actiongroups = await createRequestContextWithDataSourceId(dataSourceId).httpGet< + ObjectsMessage + >({ http, - API_ENDPOINT_ACTIONGROUPS - ); + url: API_ENDPOINT_ACTIONGROUPS, + }); return actiongroups.data; } @@ -50,10 +55,6 @@ export function transformActionGroupsToListingFormat( })); } -export async function fetchActionGroupListing(http: HttpStart): Promise { - return transformActionGroupsToListingFormat(await fetchActionGroups(http)); -} - function getClusterSinglePermissions(): PermissionListingItem[] { return CLUSTER_PERMISSIONS.map((permission) => ({ name: permission, @@ -76,10 +77,6 @@ function getIndexSinglePermissions(): PermissionListingItem[] { })); } -export async function getAllPermissionsListing(http: HttpStart): Promise { - return mergeAllPermissions(await fetchActionGroups(http)); -} - export async function mergeAllPermissions( actionGroups: DataObject ): Promise { @@ -91,13 +88,25 @@ export async function mergeAllPermissions( export async function updateActionGroup( http: HttpStart, groupName: string, - updateObject: ActionGroupUpdate + updateObject: ActionGroupUpdate, + dataSourceId: string ): Promise { - return await httpPost(http, getResourceUrl(API_ENDPOINT_ACTIONGROUPS, groupName), updateObject); + return await createRequestContextWithDataSourceId(dataSourceId).httpPost({ + http, + url: getResourceUrl(API_ENDPOINT_ACTIONGROUPS, groupName), + body: updateObject, + }); } -export async function requestDeleteActionGroups(http: HttpStart, groups: string[]) { +export async function requestDeleteActionGroups( + http: HttpStart, + groups: string[], + dataSourceId: string +) { for (const group of groups) { - await httpDelete(http, getResourceUrl(API_ENDPOINT_ACTIONGROUPS, group)); + await createRequestContextWithDataSourceId(dataSourceId).httpDelete({ + http, + url: getResourceUrl(API_ENDPOINT_ACTIONGROUPS, group), + }); } } diff --git a/public/apps/configuration/utils/audit-logging-utils.tsx b/public/apps/configuration/utils/audit-logging-utils.tsx index ecc650965..9dda1d35a 100644 --- a/public/apps/configuration/utils/audit-logging-utils.tsx +++ b/public/apps/configuration/utils/audit-logging-utils.tsx @@ -16,13 +16,27 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { AuditLoggingSettings } from '../panels/audit-logging/types'; import { API_ENDPOINT_AUDITLOGGING, API_ENDPOINT_AUDITLOGGING_UPDATE } from '../constants'; -import { httpGet, httpPost } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; -export async function updateAuditLogging(http: HttpStart, updateObject: AuditLoggingSettings) { - return await httpPost(http, API_ENDPOINT_AUDITLOGGING_UPDATE, updateObject); +export async function updateAuditLogging( + http: HttpStart, + updateObject: AuditLoggingSettings, + dataSourceId: string +) { + return await createRequestContextWithDataSourceId(dataSourceId).httpPost({ + http, + url: API_ENDPOINT_AUDITLOGGING_UPDATE, + body: updateObject, + }); } -export async function getAuditLogging(http: HttpStart): Promise { - const rawConfiguration = await httpGet(http, API_ENDPOINT_AUDITLOGGING); +export async function getAuditLogging( + http: HttpStart, + dataSourceId: string +): Promise { + const rawConfiguration = await createRequestContextWithDataSourceId(dataSourceId).httpGet({ + http, + url: API_ENDPOINT_AUDITLOGGING, + }); return rawConfiguration?.config; } diff --git a/public/apps/configuration/utils/auth-view-utils.tsx b/public/apps/configuration/utils/auth-view-utils.tsx index 147387f39..a14165fb2 100644 --- a/public/apps/configuration/utils/auth-view-utils.tsx +++ b/public/apps/configuration/utils/auth-view-utils.tsx @@ -15,9 +15,12 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { API_ENDPOINT_SECURITYCONFIG } from '../constants'; -import { httpGet } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; -export async function getSecurityConfig(http: HttpStart) { - const rawSecurityConfig = await httpGet(http, API_ENDPOINT_SECURITYCONFIG); +export async function getSecurityConfig(http: HttpStart, dataSourceId: string) { + const rawSecurityConfig = await createRequestContextWithDataSourceId(dataSourceId).httpGet({ + http, + url: API_ENDPOINT_SECURITYCONFIG, + }); return rawSecurityConfig.data.config.dynamic; } diff --git a/public/apps/configuration/utils/display-utils.tsx b/public/apps/configuration/utils/display-utils.tsx index d4b33fb62..236bcf555 100644 --- a/public/apps/configuration/utils/display-utils.tsx +++ b/public/apps/configuration/utils/display-utils.tsx @@ -70,7 +70,12 @@ export function renderCustomization(reserved: boolean, props: UIProps) { - {reserved ? 'Reserved' : 'Custom'} + + {reserved ? 'Reserved' : 'Custom'} +
); diff --git a/public/apps/configuration/utils/internal-user-detail-utils.tsx b/public/apps/configuration/utils/internal-user-detail-utils.tsx index 6293cab59..802e5a1f7 100644 --- a/public/apps/configuration/utils/internal-user-detail-utils.tsx +++ b/public/apps/configuration/utils/internal-user-detail-utils.tsx @@ -16,17 +16,29 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { API_ENDPOINT_INTERNALUSERS } from '../constants'; import { InternalUser, InternalUserUpdate } from '../types'; -import { httpGet, httpPost } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; import { getResourceUrl } from './resource-utils'; -export async function getUserDetail(http: HttpStart, username: string): Promise { - return await httpGet(http, getResourceUrl(API_ENDPOINT_INTERNALUSERS, username)); +export async function getUserDetail( + http: HttpStart, + username: string, + dataSourceId: string +): Promise { + return await createRequestContextWithDataSourceId(dataSourceId).httpGet({ + http, + url: getResourceUrl(API_ENDPOINT_INTERNALUSERS, username), + }); } export async function updateUser( http: HttpStart, username: string, - updateObject: InternalUserUpdate + updateObject: InternalUserUpdate, + dataSourceId: string ): Promise { - return await httpPost(http, getResourceUrl(API_ENDPOINT_INTERNALUSERS, username), updateObject); + return await createRequestContextWithDataSourceId(dataSourceId).httpPost({ + http, + url: getResourceUrl(API_ENDPOINT_INTERNALUSERS, username), + body: updateObject, + }); } diff --git a/public/apps/configuration/utils/internal-user-list-utils.tsx b/public/apps/configuration/utils/internal-user-list-utils.tsx index 19b0488c7..feabd585a 100644 --- a/public/apps/configuration/utils/internal-user-list-utils.tsx +++ b/public/apps/configuration/utils/internal-user-list-utils.tsx @@ -22,7 +22,7 @@ import { } from '../constants'; import { DataObject, InternalUser, ObjectsMessage } from '../types'; import { ResourceType } from '../../../../common'; -import { httpDelete, httpGet } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; import { getResourceUrl } from './resource-utils'; export interface InternalUsersListing extends InternalUser { @@ -37,32 +37,43 @@ export function transformUserData(rawData: DataObject): InternalUs })); } -export async function requestDeleteUsers(http: HttpStart, users: string[]) { +export async function requestDeleteUsers(http: HttpStart, users: string[], dataSourceId: string) { for (const user of users) { - await httpDelete(http, getResourceUrl(API_ENDPOINT_INTERNALUSERS, user)); + await createRequestContextWithDataSourceId(dataSourceId).httpDelete({ + http, + url: getResourceUrl(API_ENDPOINT_INTERNALUSERS, user), + }); } } -async function getUserListRaw( +export async function getUserListRaw( http: HttpStart, - userType: string + userType: string, + dataSourceId: string ): Promise> { let ENDPOINT = API_ENDPOINT_INTERNALACCOUNTS; if (userType === ResourceType.serviceAccounts) { ENDPOINT = API_ENDPOINT_SERVICEACCOUNTS; } - return await httpGet>(http, ENDPOINT); + return await createRequestContextWithDataSourceId(dataSourceId).httpGet< + ObjectsMessage + >({ http, url: ENDPOINT }); } export async function getUserList( http: HttpStart, - userType: string + userType: string, + dataSourceId: string ): Promise { - const rawData = await getUserListRaw(http, userType); + const rawData = await getUserListRaw(http, userType, dataSourceId); return transformUserData(rawData.data); } -export async function fetchUserNameList(http: HttpStart, userType: string): Promise { - return Object.keys((await getUserListRaw(http, userType)).data); +export async function fetchUserNameList( + http: HttpStart, + userType: string, + dataSourceId: string +): Promise { + return Object.keys((await getUserListRaw(http, userType, dataSourceId)).data); } diff --git a/public/apps/configuration/utils/request-utils.ts b/public/apps/configuration/utils/request-utils.ts index f348f49ad..2770a678d 100644 --- a/public/apps/configuration/utils/request-utils.ts +++ b/public/apps/configuration/utils/request-utils.ts @@ -13,29 +13,100 @@ * permissions and limitations under the License. */ -import { HttpStart, HttpHandler } from 'opensearch-dashboards/public'; +import { HttpStart, HttpHandler, HttpFetchQuery } from 'opensearch-dashboards/public'; +import { LOCAL_CLUSTER_ID } from '../../../../common'; -export async function request(requestFunc: HttpHandler, url: string, body?: object): Promise { - if (body) { - return (await requestFunc(url, { body: JSON.stringify(body) })) as T; - } - return (await requestFunc(url)) as T; +interface BaseRequestParams { + url: string; + body?: object; +} + +interface BaseRequestParamsWithIgnores extends BaseRequestParams { + ignores: number[]; +} + +interface CreateRequestParams extends BaseRequestParams { + http: HttpStart; +} + +interface ExecuteRequestParams extends BaseRequestParams { + requestFunc: HttpHandler; + query: HttpFetchQuery; +} + +interface CreateRequestWithIgnoreParams extends BaseRequestParamsWithIgnores { + http: HttpStart; +} +interface ExecuteRequestWithIgnoreParams extends BaseRequestParamsWithIgnores { + requestFunc: HttpHandler; + query: HttpFetchQuery; } -export async function httpGet(http: HttpStart, url: string): Promise { - return await request(http.get, url); +export function createRequestContextWithDataSourceId(dataSourceId: string) { + if (dataSourceId === undefined) { + throw new Error('dataSourceId is not present'); + } + return new RequestContext(dataSourceId); } -export async function httpPost(http: HttpStart, url: string, body?: object): Promise { - return await request(http.post, url, body); +export function createLocalClusterRequestContext() { + return new RequestContext(LOCAL_CLUSTER_ID); } -export async function httpPut(http: HttpStart, url: string, body?: object): Promise { - return await request(http.put, url, body); +export class RequestContext { + query: HttpFetchQuery; + constructor(private readonly dataSourceId: string) { + this.query = { + dataSourceId: this.dataSourceId, + }; + } + + public async httpGet(params: CreateRequestParams): Promise { + const { http, url, body } = params; + return await request({ requestFunc: http.get, url, body, query: this.query }); + } + + public async httpPost(params: CreateRequestParams): Promise { + const { http, url, body } = params; + return await request({ requestFunc: http.post, url, body, query: this.query }); + } + + public async httpPut(params: CreateRequestParams): Promise { + const { http, url, body } = params; + return await request({ requestFunc: http.put, url, body, query: this.query }); + } + + public async httpDelete(params: CreateRequestParams): Promise { + const { http, url, body } = params; + return await request({ requestFunc: http.delete, url, body, query: this.query }); + } + + public async httpDeleteWithIgnores( + params: CreateRequestWithIgnoreParams + ): Promise { + const { http, url, ignores } = params; + return await requestWithIgnores({ + requestFunc: http.delete, + url, + ignores, + query: this.query, + }); + } + + public async httpGetWithIgnores( + params: CreateRequestWithIgnoreParams + ): Promise { + const { http, url, ignores } = params; + return await requestWithIgnores({ requestFunc: http.get, url, ignores, query: this.query }); + } } -export async function httpDelete(http: HttpStart, url: string): Promise { - return await request(http.delete, url); +export async function request(params: ExecuteRequestParams): Promise { + const { requestFunc, url, body, query } = params; + if (body) { + return (await requestFunc(url, { body: JSON.stringify(body), query })) as T; + } + return (await requestFunc(url, { query })) as T; } /** @@ -45,40 +116,14 @@ export async function httpDelete(http: HttpStart, url: string): Promise { * @param ignores the error codes to be ignored */ export async function requestWithIgnores( - requestFunc: HttpHandler, - url: string, - ignores: number[], - body?: object + params: ExecuteRequestWithIgnoreParams ): Promise { + const { requestFunc, url, ignores, body, query } = params; try { - return await request(requestFunc, url, body); + return await request({ requestFunc, url, body, query }); } catch (e) { if (!ignores.includes(e?.response?.status)) { throw e; } } } - -export async function httpGetWithIgnores( - http: HttpStart, - url: string, - ignores: number[] -): Promise { - return await requestWithIgnores(http.get, url, ignores); -} - -export async function httpPostWithIgnores( - http: HttpStart, - url: string, - ignores: number[] -): Promise { - return await requestWithIgnores(http.post, url, ignores); -} - -export async function httpDeleteWithIgnores( - http: HttpStart, - url: string, - ignores: number[] -): Promise { - return await requestWithIgnores(http.delete, url, ignores); -} diff --git a/public/apps/configuration/utils/role-detail-utils.tsx b/public/apps/configuration/utils/role-detail-utils.tsx index c2525fb90..2b4415f40 100644 --- a/public/apps/configuration/utils/role-detail-utils.tsx +++ b/public/apps/configuration/utils/role-detail-utils.tsx @@ -16,13 +16,29 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { API_ENDPOINT_ROLES } from '../constants'; import { RoleDetail, RoleUpdate } from '../types'; -import { httpGet, httpPost } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; import { getResourceUrl } from './resource-utils'; -export async function getRoleDetail(http: HttpStart, roleName: string): Promise { - return await httpGet(http, getResourceUrl(API_ENDPOINT_ROLES, roleName)); +export async function getRoleDetail( + http: HttpStart, + roleName: string, + dataSourceId: string +): Promise { + return await createRequestContextWithDataSourceId(dataSourceId).httpGet({ + http, + url: getResourceUrl(API_ENDPOINT_ROLES, roleName), + }); } -export async function updateRole(http: HttpStart, roleName: string, updateObject: RoleUpdate) { - return await httpPost(http, getResourceUrl(API_ENDPOINT_ROLES, roleName), updateObject); +export async function updateRole( + http: HttpStart, + roleName: string, + updateObject: RoleUpdate, + dataSourceId: string +) { + return await createRequestContextWithDataSourceId(dataSourceId).httpPost({ + http, + url: getResourceUrl(API_ENDPOINT_ROLES, roleName), + body: updateObject, + }); } diff --git a/public/apps/configuration/utils/role-list-utils.tsx b/public/apps/configuration/utils/role-list-utils.tsx index edc44d57d..4137b0179 100644 --- a/public/apps/configuration/utils/role-list-utils.tsx +++ b/public/apps/configuration/utils/role-list-utils.tsx @@ -16,7 +16,7 @@ import { map, chain } from 'lodash'; import { HttpStart } from '../../../../../../src/core/public'; import { API_ENDPOINT_ROLES, API_ENDPOINT_ROLESMAPPING } from '../constants'; -import { httpDelete, httpDeleteWithIgnores, httpGet } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; import { getResourceUrl } from './resource-utils'; export interface RoleListing { @@ -92,19 +92,32 @@ export function buildSearchFilterOptions(roleList: any[], attrName: string): Arr } // Submit request to delete given roles. No error handling in this function. -export async function requestDeleteRoles(http: HttpStart, roles: string[]) { +export async function requestDeleteRoles(http: HttpStart, roles: string[], dataSourceId: string) { for (const role of roles) { - await httpDelete(http, getResourceUrl(API_ENDPOINT_ROLES, role)); - await httpDeleteWithIgnores(http, getResourceUrl(API_ENDPOINT_ROLESMAPPING, role), [404]); + await createRequestContextWithDataSourceId(dataSourceId).httpDelete({ + http, + url: getResourceUrl(API_ENDPOINT_ROLES, role), + }); + await createRequestContextWithDataSourceId(dataSourceId).httpDeleteWithIgnores({ + http, + url: getResourceUrl(API_ENDPOINT_ROLESMAPPING, role), + ignores: [404], + }); } } // TODO: have a type definition for it -export function fetchRole(http: HttpStart): Promise { - return httpGet(http, API_ENDPOINT_ROLES); +export function fetchRole(http: HttpStart, dataSourceId: string): Promise { + return createRequestContextWithDataSourceId(dataSourceId).httpGet({ + http, + url: API_ENDPOINT_ROLES, + }); } // TODO: have a type definition for it -export function fetchRoleMapping(http: HttpStart): Promise { - return httpGet(http, API_ENDPOINT_ROLESMAPPING); +export function fetchRoleMapping(http: HttpStart, dataSourceId: string): Promise { + return createRequestContextWithDataSourceId(dataSourceId).httpGet({ + http, + url: API_ENDPOINT_ROLESMAPPING, + }); } diff --git a/public/apps/configuration/utils/role-mapping-utils.tsx b/public/apps/configuration/utils/role-mapping-utils.tsx index 049265982..6f34d092f 100644 --- a/public/apps/configuration/utils/role-mapping-utils.tsx +++ b/public/apps/configuration/utils/role-mapping-utils.tsx @@ -17,7 +17,7 @@ import { map } from 'lodash'; import { HttpStart } from '../../../../../../src/core/public'; import { API_ENDPOINT_ROLESMAPPING } from '../constants'; import { RoleMappingDetail } from '../types'; -import { httpGetWithIgnores, httpPost } from './request-utils'; +import { createRequestContextWithDataSourceId } from './request-utils'; import { getResourceUrl } from './resource-utils'; export interface MappedUsersListing { @@ -30,12 +30,12 @@ export enum UserType { external = 'Backend role', } -export async function getRoleMappingData(http: HttpStart, roleName: string) { - return httpGetWithIgnores( +export async function getRoleMappingData(http: HttpStart, roleName: string, dataSourceId: string) { + return createRequestContextWithDataSourceId(dataSourceId).httpGetWithIgnores({ http, - getResourceUrl(API_ENDPOINT_ROLESMAPPING, roleName), - [404] - ); + url: getResourceUrl(API_ENDPOINT_ROLESMAPPING, roleName), + ignores: [404], + }); } export function transformRoleMappingData(rawData: RoleMappingDetail): MappedUsersListing[] { @@ -55,7 +55,12 @@ export function transformRoleMappingData(rawData: RoleMappingDetail): MappedUser export async function updateRoleMapping( http: HttpStart, roleName: string, - updateObject: RoleMappingDetail + updateObject: RoleMappingDetail, + dataSourceId: string ) { - return await httpPost(http, getResourceUrl(API_ENDPOINT_ROLESMAPPING, roleName), updateObject); + return await createRequestContextWithDataSourceId(dataSourceId).httpPost({ + http, + url: getResourceUrl(API_ENDPOINT_ROLESMAPPING, roleName), + body: updateObject, + }); } diff --git a/public/apps/configuration/utils/tenancy-config_util.tsx b/public/apps/configuration/utils/tenancy-config_util.tsx index b0391938e..0c9eff04a 100644 --- a/public/apps/configuration/utils/tenancy-config_util.tsx +++ b/public/apps/configuration/utils/tenancy-config_util.tsx @@ -15,14 +15,13 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { API_ENDPOINT_TENANCY_CONFIGS } from '../constants'; -import { httpGet, 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); -} +import { createLocalClusterRequestContext } from './request-utils'; export async function getTenancyConfig(http: HttpStart): Promise { - const rawConfiguration = await httpGet(http, API_ENDPOINT_TENANCY_CONFIGS); + const rawConfiguration = await createLocalClusterRequestContext().httpGet({ + http, + url: 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 799369a6e..b1df275de 100644 --- a/public/apps/configuration/utils/tenant-utils.tsx +++ b/public/apps/configuration/utils/tenant-utils.tsx @@ -36,7 +36,10 @@ import { TenantSelect, TenantUpdate, } from '../types'; -import { httpDelete, httpGet, httpPost, httpPut } from './request-utils'; +import { + createLocalClusterRequestContext, + createRequestContextWithDataSourceId, +} from './request-utils'; import { getResourceUrl } from './resource-utils'; import { DEFAULT_TENANT, @@ -61,12 +64,23 @@ export const PRIVATE_USER_DICT: { [key: string]: string } = { Description: 'Only visible to the current logged in user', }; -export async function fetchTenants(http: HttpStart): Promise> { - return (await httpGet>(http, API_ENDPOINT_TENANTS)).data; +export async function fetchTenants( + http: HttpStart, + dataSourceId: string +): Promise> { + return ( + await createRequestContextWithDataSourceId(dataSourceId).httpGet>({ + http, + url: API_ENDPOINT_TENANTS, + }) + ).data; } -export async function fetchTenantNameList(http: HttpStart): Promise { - return Object.keys(await fetchTenants(http)); +export async function fetchTenantNameList( + http: HttpStart, + dataSourceId: string +): Promise { + return Object.keys(await fetchTenants(http, dataSourceId)); } export function transformTenantData(rawTenantData: DataObject): Tenant[] { @@ -87,7 +101,10 @@ export function transformTenantData(rawTenantData: DataObject): Tenant[] } export async function fetchCurrentTenant(http: HttpStart): Promise { - return await httpGet(http, API_ENDPOINT_MULTITENANCY); + return await createLocalClusterRequestContext().httpGet({ + http, + url: API_ENDPOINT_MULTITENANCY, + }); } export async function updateTenant( @@ -95,26 +112,41 @@ export async function updateTenant( tenantName: string, updateObject: TenantUpdate ) { - return await httpPost(http, getResourceUrl(API_ENDPOINT_TENANTS, tenantName), updateObject); + return await createLocalClusterRequestContext().httpPost({ + http, + url: getResourceUrl(API_ENDPOINT_TENANTS, tenantName), + body: updateObject, + }); } export async function updateTenancyConfiguration( http: HttpStart, updatedTenancyConfig: TenancyConfigSettings ) { - await httpPut(http, API_ENDPOINT_TENANCY_CONFIGS, updatedTenancyConfig); - + // Tenancy locked to local cluster + await createLocalClusterRequestContext().httpPut({ + http, + url: API_ENDPOINT_TENANCY_CONFIGS, + body: updatedTenancyConfig, + }); return; } export async function requestDeleteTenant(http: HttpStart, tenants: string[]) { for (const tenant of tenants) { - await httpDelete(http, getResourceUrl(API_ENDPOINT_TENANTS, tenant)); + await createLocalClusterRequestContext().httpDelete({ + http, + url: getResourceUrl(API_ENDPOINT_TENANTS, tenant), + }); } } export async function selectTenant(http: HttpStart, selectObject: TenantSelect): Promise { - return await httpPost(http, API_ENDPOINT_MULTITENANCY, selectObject); + return await createLocalClusterRequestContext().httpPost({ + http, + url: API_ENDPOINT_MULTITENANCY, + body: selectObject, + }); } export const RESOLVED_GLOBAL_TENANT = 'Global'; diff --git a/public/apps/configuration/utils/test/__snapshots__/display-utils.test.tsx.snap b/public/apps/configuration/utils/test/__snapshots__/display-utils.test.tsx.snap index f81deee72..11f384b0f 100644 --- a/public/apps/configuration/utils/test/__snapshots__/display-utils.test.tsx.snap +++ b/public/apps/configuration/utils/test/__snapshots__/display-utils.test.tsx.snap @@ -16,6 +16,7 @@ exports[`Display utils Render Customization column when reserved = False 1`] = ` > Custom @@ -39,6 +40,7 @@ exports[`Display utils Render Customization column when reserved = True 1`] = ` > Reserved diff --git a/public/apps/configuration/utils/test/internal-user-list-utils.test.tsx b/public/apps/configuration/utils/test/internal-user-list-utils.test.tsx index 518e19e6a..7a0a819a4 100644 --- a/public/apps/configuration/utils/test/internal-user-list-utils.test.tsx +++ b/public/apps/configuration/utils/test/internal-user-list-utils.test.tsx @@ -13,7 +13,16 @@ * permissions and limitations under the License. */ -import { transformUserData } from '../internal-user-list-utils'; +import { fetchUserNameList, getUserList, transformUserData } from '../internal-user-list-utils'; +// Import RequestContext + +const mockedHttpGet = jest.fn().mockResolvedValue({ data: {} }); + +jest.mock('../../utils/request-utils', () => ({ + createRequestContextWithDataSourceId: jest.fn(() => ({ + httpGet: mockedHttpGet, + })), +})); describe('Internal user list utils', () => { const userList = { @@ -32,4 +41,56 @@ describe('Internal user list utils', () => { ]; expect(result).toEqual(expectedUserList); }); + + it('getUserList calls httpGet with the correct parameters for internal users', async () => { + const httpMock = {}; // Mock HttpStart object + const userType = 'internalaccounts'; + + const test = await getUserList(httpMock, userType, 'test'); + + expect(mockedHttpGet).toHaveBeenCalledWith({ + http: httpMock, + url: '/api/v1/configuration/internalaccounts', + }); + expect(test).toEqual([]); + }); + + it('getUserList calls httpGet with the correct parameters for service accounts', async () => { + const httpMock = {}; + const userType = 'serviceAccounts'; + + const test = await getUserList(httpMock, userType, 'test'); + + expect(mockedHttpGet).toHaveBeenCalledWith({ + http: httpMock, + url: '/api/v1/configuration/serviceaccounts', + }); + expect(test).toEqual([]); + }); + + it('fetchUserNameList calls httpGet with the correct parameters for service accounts', async () => { + const httpMock = {}; + const userType = 'serviceAccounts'; + + const test = await fetchUserNameList(httpMock, userType, ''); + + expect(mockedHttpGet).toHaveBeenCalledWith({ + http: httpMock, + url: '/api/v1/configuration/serviceaccounts', + }); + expect(test).toEqual([]); + }); + + it('fetchUserNameList calls httpGet with the correct parameters for internal users', async () => { + const httpMock = {}; + const userType = 'internalaccounts'; + + const test = await fetchUserNameList(httpMock, userType, ''); + + expect(mockedHttpGet).toHaveBeenCalledWith({ + http: httpMock, + url: '/api/v1/configuration/internalaccounts', + }); + expect(test).toEqual([]); + }); }); diff --git a/public/apps/configuration/utils/test/request-utils.test.ts b/public/apps/configuration/utils/test/request-utils.test.ts new file mode 100644 index 000000000..d75b64a71 --- /dev/null +++ b/public/apps/configuration/utils/test/request-utils.test.ts @@ -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 * as requestUtils from '../request-utils'; +import { HttpStart } from 'opensearch-dashboards/public'; + +describe('RequestContext', () => { + let httpMock: HttpStart; + + beforeEach(() => { + // Mocking HttpStart + httpMock = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should error if no dataSourceId is passed', () => { + expect(() => createRequestContextWithDataSourceId()).toThrowError(); + }); + + it('should have the correct query based on the passed dataSourceId', () => { + const context = requestUtils.createRequestContextWithDataSourceId('test'); + expect(context.query).toEqual({ dataSourceId: 'test' }); + }); + + it('should have the correct query based on local cluster context', () => { + const context = requestUtils.createLocalClusterRequestContext(); + expect(context.query).toEqual({ dataSourceId: '' }); + }); +}); diff --git a/public/apps/configuration/utils/test/toast-utils.test.tsx b/public/apps/configuration/utils/test/toast-utils.test.tsx index 921751da5..82b6b8ad9 100644 --- a/public/apps/configuration/utils/test/toast-utils.test.tsx +++ b/public/apps/configuration/utils/test/toast-utils.test.tsx @@ -101,21 +101,30 @@ describe('Toast utils', () => { describe('getSuccessToastMessage', () => { it('should return successful create message', () => { - const result = getSuccessToastMessage('User', 'create', 'user1'); + const result = getSuccessToastMessage('User', 'create', 'user1', false, { id: '' }); - expect(result).toEqual('User "user1" successfully created'); + expect(result).toEqual('User "user1" successfully created '); }); it('should return successful update message', () => { - const result = getSuccessToastMessage('Role', 'edit', 'role1'); + const result = getSuccessToastMessage('Role', 'edit', 'role1', false, { id: '' }); - expect(result).toEqual('Role "role1" successfully updated'); + expect(result).toEqual('Role "role1" successfully updated '); }); it('should return empty message for unknown action', () => { - const result = getSuccessToastMessage('User', '', 'user1'); + const result = getSuccessToastMessage('User', '', 'user1', false, { id: '' }); expect(result).toEqual(''); }); + + it('should return successful update message for remote cluster', () => { + const result = getSuccessToastMessage('Role', 'edit', 'role1', true, { + id: '', + label: 'blah', + }); + + expect(result).toEqual('Role "role1" successfully updated for blah'); + }); }); }); diff --git a/public/apps/configuration/utils/toast-utils.tsx b/public/apps/configuration/utils/toast-utils.tsx index db6fea635..218453cf6 100644 --- a/public/apps/configuration/utils/toast-utils.tsx +++ b/public/apps/configuration/utils/toast-utils.tsx @@ -15,6 +15,8 @@ import { Toast } from '@elastic/eui/src/components/toast/global_toast_list'; import { useState, useCallback } from 'react'; +import { getClusterInfo } from '../../../utils/datasource-utils'; +import { DataSourceOption } from '../../../../../../src/plugins/data_source_management/public/components/data_source_menu/types'; export function createErrorToast(id: string, title: string, text: string): Toast { return { @@ -66,14 +68,22 @@ export function useToastState(): [Toast[], (toAdd: Toast) => void, (toDelete: To export function getSuccessToastMessage( resourceType: string, action: string, - userName: string + userName: string, + dataSourceEnabled: boolean, + dataSource: DataSourceOption ): string { switch (action) { case 'create': case 'duplicate': - return `${resourceType} "${userName}" successfully created`; + return `${resourceType} "${userName}" successfully created ${getClusterInfo( + dataSourceEnabled, + dataSource + )}`; case 'edit': - return `${resourceType} "${userName}" successfully updated`; + return `${resourceType} "${userName}" successfully updated ${getClusterInfo( + dataSourceEnabled, + dataSource + )}`; default: return ''; } diff --git a/public/apps/types.ts b/public/apps/types.ts index 43d723563..276f1ce07 100644 --- a/public/apps/types.ts +++ b/public/apps/types.ts @@ -13,15 +13,17 @@ * permissions and limitations under the License. */ +import { DataSourceManagementPluginSetup } from '../../../../src/plugins/data_source_management/public'; import { AppMountParameters, CoreStart } from '../../../../src/core/public'; import { SecurityPluginStartDependencies, ClientConfigType, DashboardsInfo } from '../types'; export interface AppDependencies { coreStart: CoreStart; - navigation: SecurityPluginStartDependencies; + depsStart: SecurityPluginStartDependencies; params: AppMountParameters; config: ClientConfigType; dashboardsInfo: DashboardsInfo; + dataSourceManagement: DataSourceManagementPluginSetup; } export interface BreadcrumbsPageDependencies extends AppDependencies { diff --git a/public/plugin.ts b/public/plugin.ts index 7ac039fb1..ca9798a94 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -112,7 +112,13 @@ export class SecurityPlugin excludeFromDisabledTransportCategories(config.disabledTransportCategories.exclude); excludeFromDisabledRestCategories(config.disabledRestCategories.exclude); - return renderApp(coreStart, depsStart as SecurityPluginStartDependencies, params, config); + return renderApp( + coreStart, + depsStart as SecurityPluginStartDependencies, + params, + config, + deps.dataSourceManagement + ); }, category: DEFAULT_APP_CATEGORIES.management, }); diff --git a/public/types.ts b/public/types.ts index 4acfc442f..96e587354 100644 --- a/public/types.ts +++ b/public/types.ts @@ -19,6 +19,8 @@ import { SavedObjectsManagementPluginStart, } from '../../../src/plugins/saved_objects_management/public'; import { ManagementOverViewPluginSetup } from '../../../src/plugins/management_overview/public'; +import { DataSourcePluginStart } from '../../../src/plugins/data_source/public/types'; +import { DataSourceManagementPluginSetup } from '../../../src/plugins/data_source_management/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecurityPluginSetup {} @@ -28,11 +30,18 @@ export interface SecurityPluginStart {} export interface SecurityPluginSetupDependencies { savedObjectsManagement: SavedObjectsManagementPluginSetup; managementOverview?: ManagementOverViewPluginSetup; + dataSourceManagement?: DataSourceManagementPluginSetup; +} + +export interface Cluster { + id: string; + label: string; } export interface SecurityPluginStartDependencies { navigation: NavigationPublicPluginStart; savedObjectsManagement: SavedObjectsManagementPluginStart; + dataSource?: DataSourcePluginStart; } export interface AuthInfo { diff --git a/public/utils/auth-info-utils.tsx b/public/utils/auth-info-utils.tsx index f91dd2299..eaa3292a8 100644 --- a/public/utils/auth-info-utils.tsx +++ b/public/utils/auth-info-utils.tsx @@ -15,11 +15,14 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { API_ENDPOINT_AUTHINFO } from '../../common'; -import { httpGet } from '../apps/configuration/utils/request-utils'; import { AuthInfo } from '../types'; +import { createLocalClusterRequestContext } from '../apps/configuration/utils/request-utils'; export async function getAuthInfo(http: HttpStart) { - return await httpGet(http, API_ENDPOINT_AUTHINFO); + return await createLocalClusterRequestContext().httpGet({ + http, + url: API_ENDPOINT_AUTHINFO, + }); } export async function getCurrentUser(http: HttpStart) { diff --git a/public/utils/dashboards-info-utils.tsx b/public/utils/dashboards-info-utils.tsx index eeb76a345..400e4da1b 100644 --- a/public/utils/dashboards-info-utils.tsx +++ b/public/utils/dashboards-info-utils.tsx @@ -15,13 +15,20 @@ 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 { createLocalClusterRequestContext } from '../apps/configuration/utils/request-utils'; export async function getDashboardsInfo(http: HttpStart) { - return await httpGet(http, API_ENDPOINT_DASHBOARDSINFO); + return await createLocalClusterRequestContext().httpGet({ + http, + url: API_ENDPOINT_DASHBOARDSINFO, + }); } export async function getDashboardsInfoSafe(http: HttpStart): Promise { - return httpGetWithIgnores(http, API_ENDPOINT_DASHBOARDSINFO, [401]); + return createLocalClusterRequestContext().httpGetWithIgnores({ + http, + url: API_ENDPOINT_DASHBOARDSINFO, + ignores: [401], + }); } diff --git a/public/utils/datasource-utils.ts b/public/utils/datasource-utils.ts new file mode 100644 index 000000000..f6f1706b1 --- /dev/null +++ b/public/utils/datasource-utils.ts @@ -0,0 +1,41 @@ +/* + * 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 { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types'; + +export function createDataSourceQuery(dataSourceId: string) { + return { dataSourceId }; +} + +const DATASOURCEURLKEY = 'dataSource'; + +export function getClusterInfo(dataSourceEnabled: boolean, cluster: DataSourceOption) { + if (dataSourceEnabled) { + return `for ${cluster.label || 'Local cluster'}`; + } + return ''; +} + +export function getDataSourceFromUrl(): DataSourceOption { + const urlParams = new URLSearchParams(window.location.search); + const dataSourceParam = (urlParams && urlParams.get(DATASOURCEURLKEY)) || '{}'; + return JSON.parse(dataSourceParam); +} + +export function setDataSourceInUrl(dataSource: DataSourceOption) { + const url = new URL(window.location.href); + url.searchParams.set(DATASOURCEURLKEY, JSON.stringify(dataSource)); + window.history.replaceState({}, '', url.toString()); +} diff --git a/public/utils/login-utils.tsx b/public/utils/login-utils.tsx index e66080c67..85d63e354 100644 --- a/public/utils/login-utils.tsx +++ b/public/utils/login-utils.tsx @@ -14,15 +14,19 @@ */ import { HttpStart } from 'opensearch-dashboards/public'; -import { httpPost } from '../apps/configuration/utils/request-utils'; +import { createLocalClusterRequestContext } from '../apps/configuration/utils/request-utils'; export async function validateCurrentPassword( http: HttpStart, userName: string, currentPassword: string ): Promise { - await httpPost(http, '/auth/login', { - username: userName, - password: currentPassword, + await createLocalClusterRequestContext().httpPost({ + http, + url: '/auth/login', + body: { + username: userName, + password: currentPassword, + }, }); } diff --git a/public/utils/logout-utils.tsx b/public/utils/logout-utils.tsx index 87bb9d967..7ab38e459 100644 --- a/public/utils/logout-utils.tsx +++ b/public/utils/logout-utils.tsx @@ -19,7 +19,7 @@ import { IHttpInterceptController, } from '../../../../src/core/public'; import { API_ENDPOINT_AUTHTYPE, CUSTOM_ERROR_PAGE_URI, LOGIN_PAGE_URI } from '../../common'; -import { httpGet } from '../apps/configuration/utils/request-utils'; +import { createLocalClusterRequestContext } from '../apps/configuration/utils/request-utils'; import { setShouldShowTenantPopup } from './storage-utils'; export function interceptError(logoutUrl: string, thisWindow: Window): any { @@ -47,5 +47,8 @@ export function interceptError(logoutUrl: string, thisWindow: Window): any { } export async function fetchCurrentAuthType(http: HttpStart): Promise { - return await httpGet(http, API_ENDPOINT_AUTHTYPE); + return await createLocalClusterRequestContext().httpGet({ + http, + url: API_ENDPOINT_AUTHTYPE, + }); } diff --git a/public/utils/test/datasource-utils.test.ts b/public/utils/test/datasource-utils.test.ts new file mode 100644 index 000000000..e05fdb1b8 --- /dev/null +++ b/public/utils/test/datasource-utils.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { + createDataSourceQuery, + getClusterInfo, + getDataSourceFromUrl, + setDataSourceInUrl, +} from '../datasource-utils'; + +describe('Tests datasource utils', () => { + it('Tests the GetClusterDescription helper function', () => { + expect(getClusterInfo(false, { id: 'blah', label: 'blah' })).toBe(''); + expect(getClusterInfo(true, { id: '', label: '' })).toBe('for Local cluster'); + expect(getClusterInfo(true, { id: 'test', label: 'test' })).toBe('for test'); + }); + + it('Tests the create DataSource query helper function', () => { + expect(createDataSourceQuery('test')).toStrictEqual({ dataSourceId: 'test' }); + }); + + it('Tests getting the datasource from the url', () => { + const mockSearchNoDataSourceId = '?foo=bar&baz=qux'; + Object.defineProperty(window, 'location', { + value: { search: mockSearchNoDataSourceId }, + writable: true, + }); + expect(getDataSourceFromUrl()).toEqual({}); + const mockSearchDataSourceIdNotfirst = + '?foo=bar&baz=qux&dataSource=%7B"id"%3A"94ffa650-f11a-11ee-a585-793f7b098e1a"%2C"label"%3A"9202"%7D'; + Object.defineProperty(window, 'location', { + value: { search: mockSearchDataSourceIdNotfirst }, + writable: true, + }); + expect(getDataSourceFromUrl()).toEqual({ + id: '94ffa650-f11a-11ee-a585-793f7b098e1a', + label: '9202', + }); + const mockSearchDataSourceIdFirst = + '?dataSource=%7B"id"%3A"94ffa650-f11a-11ee-a585-793f7b098e1a"%2C"label"%3A"9202"%7D'; + Object.defineProperty(window, 'location', { + value: { search: mockSearchDataSourceIdFirst }, + writable: true, + }); + expect(getDataSourceFromUrl()).toEqual({ + id: '94ffa650-f11a-11ee-a585-793f7b098e1a', + label: '9202', + }); + }); + + it('Tests setting the datasource in the url', () => { + const replaceState = jest.fn(); + const mockUrl = 'http://localhost:5601/app/security-dashboards-plugin#/auth'; + Object.defineProperty(window, 'location', { + value: { href: mockUrl }, + writable: true, + }); + Object.defineProperty(window, 'history', { + value: { replaceState }, + writable: true, + }); + setDataSourceInUrl({ id: '', label: 'Local cluster' }); + expect(replaceState).toBeCalledWith( + {}, + '', + 'http://localhost:5601/app/security-dashboards-plugin?dataSource=%7B%22id%22%3A%22%22%2C%22label%22%3A%22Local+cluster%22%7D#/auth' + ); + }); +}); diff --git a/server/plugin.ts b/server/plugin.ts index 5f5f50913..a6d87bea1 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -46,12 +46,17 @@ import { createMigrationOpenSearchClient } from '../../../src/core/server/saved_ import { SecuritySavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper'; import { addTenantParameterToResolvedShortLink } from './multitenancy/tenant_resolver'; import { ReadonlyService } from './readonly/readonly_service'; +import { DataSourcePluginSetup } from '../../../src/plugins/data_source/server/types'; export interface SecurityPluginRequestContext { logger: Logger; esClient: ILegacyClusterClient; } +export interface SecurityPluginSetupDependencies { + dataSource: DataSourcePluginSetup; +} + declare module 'opensearch-dashboards/server' { interface RequestHandlerContext { security_plugin: SecurityPluginRequestContext; @@ -83,8 +88,9 @@ export class SecurityPlugin implements Plugin(); const config: SecurityPluginConfigType = await config$.pipe(first()).toPromise(); @@ -97,6 +103,10 @@ export class SecurityPlugin implements Plugin> => { - const client = context.security_plugin.esClient.asScoped(request); - let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.getResource', { - resourceName: request.params.resourceName, - id: request.params.id, - }); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.getResource', + { + resourceName: request.params.resourceName, + id: request.params.id, + } + ); return response.ok({ body: esResp[request.params.id] }); } catch (error) { return errorResponse(response, error); @@ -384,6 +405,9 @@ export function defineRoutes(router: IRouter) { minLength: 1, }), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async ( @@ -391,13 +415,17 @@ export function defineRoutes(router: IRouter) { request, response ): Promise> => { - const client = context.security_plugin.esClient.asScoped(request); - let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.deleteResource', { - resourceName: request.params.resourceName, - id: request.params.id, - }); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.deleteResource', + { + resourceName: request.params.resourceName, + id: request.params.id, + } + ); return response.ok({ body: { message: esResp.message, @@ -427,6 +455,9 @@ export function defineRoutes(router: IRouter) { resourceName: schema.string(), }), body: schema.any(), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async ( @@ -439,13 +470,17 @@ export function defineRoutes(router: IRouter) { } catch (error) { return response.badRequest({ body: error }); } - const client = context.security_plugin.esClient.asScoped(request); - let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.saveResourceWithoutId', { - resourceName: request.params.resourceName, - body: request.body, - }); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.saveResourceWithoutId', + { + resourceName: request.params.resourceName, + body: request.body, + } + ); return response.ok({ body: { message: esResp.message, @@ -471,6 +506,9 @@ export function defineRoutes(router: IRouter) { }), }), body: schema.any(), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async ( @@ -483,14 +521,18 @@ export function defineRoutes(router: IRouter) { } catch (error) { return response.badRequest({ body: error }); } - const client = context.security_plugin.esClient.asScoped(request); - let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.saveResource', { - resourceName: request.params.resourceName, - id: request.params.id, - body: request.body, - }); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.saveResource', + { + resourceName: request.params.resourceName, + id: request.params.id, + body: request.body, + } + ); return response.ok({ body: { message: esResp.message, @@ -632,18 +674,24 @@ export function defineRoutes(router: IRouter) { router.get( { path: `${API_PREFIX}/configuration/audit`, - validate: false, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, }, async ( context, request, response ): Promise> => { - const client = context.security_plugin.esClient.asScoped(request); - - let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.getAudit'); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.getAudit' + ); return response.ok({ body: esResp, @@ -717,15 +765,22 @@ export function defineRoutes(router: IRouter) { path: `${API_PREFIX}/configuration/audit/config`, validate: { body: schema.any(), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { - const client = context.security_plugin.esClient.asScoped(request); - let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.saveAudit', { - body: request.body, - }); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.saveAudit', + { + body: request.body, + } + ); return response.ok({ body: { message: esResp.message, @@ -745,16 +800,23 @@ export function defineRoutes(router: IRouter) { router.delete( { path: `${API_PREFIX}/configuration/cache`, - validate: false, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, }, async (context, request, response) => { - const client = context.security_plugin.esClient.asScoped(request); - let esResponse; try { - esResponse = await client.callAsCurrentUser('opensearch_security.clearCache'); + const esResp = await wrapRouteWithDataSource( + dataSourceEnabled, + context, + request, + 'opensearch_security.clearCache' + ); return response.ok({ body: { - message: esResponse.message, + message: esResp.message, }, }); } catch (error) { @@ -883,6 +945,27 @@ export function defineRoutes(router: IRouter) { ); } +/** + * A helper function to wrap API calls with the appropriate client. If the multiple datasources feature is disabled, it will use + * the existing client provided by the security plugin. Otherwise, it will use the one provided by the datasource plugin based on the id + * that we extract via the UI. + */ +const wrapRouteWithDataSource = async ( + dataSourceEnabled: boolean, + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + endpoint: string, + body?: Record +) => { + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.security_plugin.esClient.asScoped(request); + return await client.callAsCurrentUser(endpoint, body); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query?.dataSourceId); + return await client.callAPI(endpoint, body); + } +}; + function parseEsErrorResponse(error: any) { if (error.response) { try { diff --git a/test/cypress/e2e/multi-datasources/multi_datasources_disabled.spec.js b/test/cypress/e2e/multi-datasources/multi_datasources_disabled.spec.js new file mode 100644 index 000000000..8cc4495b9 --- /dev/null +++ b/test/cypress/e2e/multi-datasources/multi_datasources_disabled.spec.js @@ -0,0 +1,27 @@ +/* + * 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. + */ + +describe('Multi-datasources enabled', () => { + it('Sanity checks the cluster selector is not visible when multi datasources is disabled', () => { + localStorage.setItem('opendistro::security::tenant::saved', '""'); + localStorage.setItem('home:newThemeModal:show', 'false'); + + cy.visit('http://localhost:5601/app/security-dashboards-plugin#/getstarted', { + failOnStatusCode: false, + }); + + cy.get('[data-test-subj="dataSourceSelectableContextMenuHeaderLink"]').should('not.exist'); + }); +}); diff --git a/test/cypress/e2e/multi-datasources/multi_datasources_enabled.spec.js b/test/cypress/e2e/multi-datasources/multi_datasources_enabled.spec.js new file mode 100644 index 000000000..2a1a0c4f7 --- /dev/null +++ b/test/cypress/e2e/multi-datasources/multi_datasources_enabled.spec.js @@ -0,0 +1,240 @@ +/* + * 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. + */ + +const createDataSource = () => { + return cy.request({ + method: 'POST', + url: `${Cypress.config('baseUrl')}/api/saved_objects/data-source`, + headers: { + 'osd-xsrf': true, + }, + body: { + attributes: { + title: Cypress.env('externalDataSourceLabel'), + endpoint: Cypress.env('externalDataSourceEndpoint'), + auth: { + type: 'username_password', + credentials: { + username: Cypress.env('externalDataSourceAdminUserName'), + password: Cypress.env('externalDataSourceAdminPassword'), + }, + }, + }, + }, + }); +}; + +const deleteAllDataSources = () => { + cy.request( + 'GET', + `${Cypress.config( + 'baseUrl' + )}/api/saved_objects/_find?fields=id&fields=description&fields=title&per_page=10000&type=data-source` + ).then((resp) => { + if (resp && resp.body && resp.body.saved_objects) { + resp.body.saved_objects.map(({ id }) => { + cy.request({ + method: 'DELETE', + url: `${Cypress.config('baseUrl')}/api/saved_objects/data-source/${id}`, + body: { force: false }, + headers: { + 'osd-xsrf': true, + }, + }); + }); + } + }); +}; + +const createUrlParam = (label, id) => { + const dataSourceObj = { label, id }; + return `?dataSource=${JSON.stringify(dataSourceObj).toString()}`; +}; + +let externalDataSourceId; +let externalDataSourceUrl; +let localDataSourceUrl; + +describe('Multi-datasources enabled', () => { + beforeEach(() => { + localStorage.setItem('opendistro::security::tenant::saved', '""'); + localStorage.setItem('home:newThemeModal:show', 'false'); + createDataSource().then((resp) => { + if (resp && resp.body) { + externalDataSourceId = resp.body.id; + } + externalDataSourceUrl = createUrlParam( + Cypress.env('externalDataSourceLabel'), + externalDataSourceId + ); + localDataSourceUrl = createUrlParam('Local cluster', ''); + }); + }); + + afterEach(() => { + cy.clearCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + deleteAllDataSources(); + }); + + it('Checks Get Started Tab', () => { + // Remote cluster purge cache + cy.visit( + `http://localhost:5601/app/security-dashboards-plugin${externalDataSourceUrl}#/getstarted` + ); + + cy.contains('h1', 'Get started'); + cy.get('[data-test-subj="dataSourceSelectableButton"]').should('contain', '9202'); + + cy.get('[data-test-subj="purge-cache"]').click(); + cy.get('[class="euiToast euiToast--success euiGlobalToastListItem"]') + .get('.euiToastHeader__title') + .should('contain', 'successful for 9202'); + }); + + it('Checks Auth Tab', () => { + cy.visit(`http://localhost:5601/app/security-dashboards-plugin${externalDataSourceUrl}#/auth`); + + cy.get('.panel-header-count').first().invoke('text').should('contain', '(1)'); + }); + + it('Checks Users Tab', () => { + cy.request({ + method: 'POST', + url: `http://localhost:5601/api/v1/configuration/internalusers/9202-user?dataSourceId=${externalDataSourceId}`, + headers: { + 'osd-xsrf': true, + }, + body: { + backend_roles: [''], + attributes: {}, + password: 'myStrongPassword12345678!', + }, + }).then(() => { + cy.visit( + `http://localhost:5601/app/security-dashboards-plugin${externalDataSourceUrl}#/users` + ); + + cy.get('[data-test-subj="tableHeaderCell_username_0"]').click(); + cy.get('[data-test-subj="checkboxSelectRow-9202-user"]').should('exist'); + }); + }); + + it('Checks Permissions Tab', () => { + cy.request({ + method: 'POST', + url: `http://localhost:5601/api/v1/configuration/actiongroups/9202-permission?dataSourceId=${externalDataSourceId}`, + headers: { + 'osd-xsrf': true, + }, + body: { + allowed_actions: [], + }, + }).then(() => { + cy.visit( + `http://localhost:5601/app/security-dashboards-plugin${externalDataSourceUrl}#/permissions` + ); + + // Permission exists on the remote data source + cy.get('[data-test-subj="tableHeaderCell_name_0"]').click(); + cy.get('[data-test-subj="checkboxSelectRow-9202-permission"]').should('exist'); + }); + }); + + it('Checks Tenancy Tab', () => { + // Datasource is locked to local cluster for tenancy tab + cy.visit(`http://localhost:5601/app/security-dashboards-plugin${localDataSourceUrl}#/tenants`); + + cy.contains('h1', 'Dashboards multi-tenancy'); + cy.get('[data-test-subj="dataSourceViewButton"]').should('contain', 'Local cluster'); + }); + + it('Checks Service Accounts Tab', () => { + // Datasource is locked to local cluster for service accounts tab + cy.visit( + `http://localhost:5601/app/security-dashboards-plugin${localDataSourceUrl}#/serviceAccounts` + ); + + cy.get('[data-test-subj="dataSourceViewButton"]').should('contain', 'Local cluster'); + }); + + it('Checks Audit Logs Tab', () => { + cy.request({ + method: 'POST', + url: `http://localhost:5601/api/v1/configuration/audit/config?dataSourceId=${externalDataSourceId}`, + headers: { + 'osd-xsrf': true, + }, + body: { + compliance: { + enabled: true, + write_log_diffs: false, + read_watched_fields: {}, + read_ignore_users: ['kibanaserver'], + write_watched_indices: [], + write_ignore_users: ['kibanaserver'], + internal_config: false, + read_metadata_only: false, + write_metadata_only: false, + external_config: false, + }, + enabled: false, + audit: { + ignore_users: ['kibanaserver'], + ignore_requests: [], + disabled_rest_categories: ['AUTHENTICATED', 'GRANTED_PRIVILEGES'], + disabled_transport_categories: ['AUTHENTICATED', 'GRANTED_PRIVILEGES'], + log_request_body: true, + resolve_indices: true, + resolve_bulk_requests: false, + enable_transport: true, + enable_rest: true, + exclude_sensitive_headers: true, + }, + }, + }).then(() => { + cy.visit( + `http://localhost:5601/app/security-dashboards-plugin${externalDataSourceUrl}#/auditLogging` + ); + cy.get('[class="euiSwitch__label"]').should('contain', 'Disabled'); + }); + }); + + it('Checks Roles Tab', () => { + cy.request({ + method: 'POST', + url: `http://localhost:5601/api/v1/configuration/roles/9202-role?dataSourceId=${externalDataSourceId}`, + headers: { + 'osd-xsrf': true, + }, + body: { + cluster_permissions: [], + index_permissions: [], + tenant_permissions: [], + }, + }).then(() => { + cy.visit( + `http://localhost:5601/app/security-dashboards-plugin${externalDataSourceUrl}#/roles` + ); + + cy.get('[data-test-subj="dataSourceSelectableButton"]').should('contain', '9202'); + cy.get('[data-test-subj="tableHeaderCell_roleName_0"]').click(); + cy.get('[data-test-subj="checkboxSelectRow-9202-role"]').should('exist'); + + // role exists on the remote + }); + }); +}); diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js index 29c565062..cdab9ed22 100644 --- a/test/cypress/support/commands.js +++ b/test/cypress/support/commands.js @@ -91,6 +91,20 @@ Cypress.Commands.add('loginWithSamlMultiauth', () => { cy.get('button[id=btn-sign-in]').should('be.visible').click(); }); +if (Cypress.env('LOGIN_AS_ADMIN')) { + // Define custom cy.visit() only if LOGIN_AS_ADMIN is true + Cypress.Commands.overwrite('visit', (orig, url, options = {}) => { + if (Cypress.env('LOGIN_AS_ADMIN')) { + options.auth = ADMIN_AUTH; + options.failOnStatusCode = false; + options.qs = { + security_tenant: 'private', + }; + } + orig(url, options); + }); +} + Cypress.Commands.add('shortenUrl', (data, tenant) => { cy.request({ url: `http://localhost:5601${DASHBOARDS_API.SHORTEN_URL}`, diff --git a/test/helper/entity_operation.ts b/test/helper/entity_operation.ts index a35b94378..aad5da56f 100644 --- a/test/helper/entity_operation.ts +++ b/test/helper/entity_operation.ts @@ -33,3 +33,48 @@ export async function getEntityAsAdmin(root: Root, entityType: string, entityId: .get(root, `/api/v1/configuration/${entityType}/${entityId}`) .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); } + +export async function createOrUpdateEntityAsAdminWithDataSource( + root: Root, + entityType: string, + entityId: string, + body: any, + dataSourceId: string +) { + return await osdTestServer.request + .post(root, `/api/v1/configuration/${entityType}/${entityId}?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send(body); +} + +export async function getEntityAsAdminWithDataSource( + root: Root, + entityType: string, + entityId: string, + dataSourceId: string +) { + return await osdTestServer.request + .get(root, `/api/v1/configuration/${entityType}/${entityId}?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); +} + +export async function getAllEntitiesAsAdminWithDataSource( + root: Root, + entityType: string, + dataSourceId: string +) { + return await osdTestServer.request + .get(root, `/api/v1/configuration/${entityType}?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); +} + +export async function deleteEntityAsAdminWithDataSource( + root: Root, + entityType: string, + entityId: string, + dataSourceId: string +) { + return await osdTestServer.request + .delete(root, `/api/v1/configuration/${entityType}/${entityId}?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); +} diff --git a/test/jest_integration/constants.ts b/test/jest_integration/constants.ts new file mode 100644 index 000000000..30516f730 --- /dev/null +++ b/test/jest_integration/constants.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +export const testAuditLogDisabledSettings = { + enabled: false, + audit: { + enable_rest: false, + disabled_rest_categories: ['FAILED_LOGIN', 'AUTHENTICATED'], + enable_transport: true, + disabled_transport_categories: ['GRANTED_PRIVILEGES'], + resolve_bulk_requests: true, + log_request_body: false, + resolve_indices: true, + exclude_sensitive_headers: true, + ignore_users: ['admin'], + ignore_requests: ['SearchRequest', 'indices:data/read/*'], + }, + compliance: { + enabled: true, + internal_config: false, + external_config: false, + read_metadata_only: false, + read_watched_fields: { + indexName1: ['field1', 'fields-*'], + }, + read_ignore_users: ['opensearchdashboardsserver', 'operator/*'], + write_metadata_only: false, + write_log_diffs: false, + write_watched_indices: ['indexName2', 'indexPatterns-*'], + write_ignore_users: ['admin'], + }, +}; + +export const testAuditLogEnabledSettings = { + enabled: true, + audit: { + enable_rest: false, + disabled_rest_categories: ['FAILED_LOGIN', 'AUTHENTICATED'], + enable_transport: true, + disabled_transport_categories: ['GRANTED_PRIVILEGES'], + resolve_bulk_requests: true, + log_request_body: false, + resolve_indices: true, + exclude_sensitive_headers: true, + ignore_users: ['admin'], + ignore_requests: ['SearchRequest', 'indices:data/read/*'], + }, + compliance: { + enabled: true, + internal_config: false, + external_config: false, + read_metadata_only: false, + read_watched_fields: { + indexName1: ['field1', 'fields-*'], + }, + read_ignore_users: ['opensearchdashboardsserver', 'operator/*'], + write_metadata_only: false, + write_log_diffs: false, + write_watched_indices: ['indexName2', 'indexPatterns-*'], + write_ignore_users: ['admin'], + }, +}; diff --git a/test/jest_integration/security_entity_api.test.ts b/test/jest_integration/security_entity_api.test.ts index f28817798..ecae09cac 100644 --- a/test/jest_integration/security_entity_api.test.ts +++ b/test/jest_integration/security_entity_api.test.ts @@ -26,7 +26,15 @@ import { AUTHORIZATION_HEADER_NAME, } from '../constant'; import { extractAuthCookie, getAuthCookie } from '../helper/cookie'; -import { createOrUpdateEntityAsAdmin, getEntityAsAdmin } from '../helper/entity_operation'; +import { + createOrUpdateEntityAsAdmin, + createOrUpdateEntityAsAdminWithDataSource, + deleteEntityAsAdminWithDataSource, + getAllEntitiesAsAdminWithDataSource, + getEntityAsAdmin, + getEntityAsAdminWithDataSource, +} from '../helper/entity_operation'; +import { testAuditLogDisabledSettings, testAuditLogEnabledSettings } from './constants'; describe('start OpenSearch Dashboards server', () => { let root: Root; @@ -351,15 +359,31 @@ describe('start OpenSearch Dashboards server', () => { it('delete cache', async () => { const deleteCacheResponse = await osdTestServer.request .delete(root, '/api/v1/configuration/cache') - .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send({ dataSourceId: '' }); expect(deleteCacheResponse.status).toEqual(200); + // Multi datasources not enabled so dataSourceId is not read + const deleteCacheResponseMultiDataSource = await osdTestServer.request + .delete(root, '/api/v1/configuration/cache') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send({ dataSourceId: 'Derek Datasource' }); + expect(deleteCacheResponseMultiDataSource.status).toEqual(200); + const adminAuthCookie = await getAuthCookie(root, ADMIN_USER, ADMIN_PASSWORD); const deleteCacheWithCookieResponse = await osdTestServer.request .delete(root, '/api/v1/configuration/cache') .unset(AUTHORIZATION_HEADER_NAME) - .set('Cookie', adminAuthCookie); + .set('Cookie', adminAuthCookie) + .send({ dataSourceId: '' }); expect(deleteCacheWithCookieResponse.status).toEqual(200); + + // Multi datasources not enabled so dataSourceId is not read + const deleteCacheResponseMultiDataSourceCookie = await osdTestServer.request + .delete(root, '/api/v1/configuration/cache') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send({ dataSourceId: 'Derek Datasource' }); + expect(deleteCacheResponseMultiDataSourceCookie.status).toEqual(200); }); it('restapiinfo', async () => { @@ -405,4 +429,480 @@ describe('start OpenSearch Dashboards server', () => { }); expect(response.status).toEqual(200); }); + + it('Audit logging', async () => { + const getAuditLoggingResponse = await osdTestServer.request + .get(root, '/api/v1/configuration/audit') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + expect(getAuditLoggingResponse.status).toEqual(200); + + const changeAuditLogResponse = await osdTestServer.request + .post(root, '/api/v1/configuration/audit/config') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send(testAuditLogEnabledSettings); + expect(changeAuditLogResponse.status).toEqual(200); + }); +}); + +describe('start OpenSearch Dashboards server multi datasources enabled', () => { + let root: Root; + let dataSourceId: string; + + beforeAll(async () => { + root = osdTestServer.createRootWithSettings( + { + plugins: { + scanDirs: [resolve(__dirname, '../..')], + }, + data_source: { enabled: true, ssl: { verificationMode: 'none' } }, + opensearch: { + hosts: ['https://localhost:9200'], + ignoreVersionMismatch: true, + ssl: { verificationMode: 'none' }, + username: OPENSEARCH_DASHBOARDS_SERVER_USER, + password: OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + }, + opensearch_security: { + multitenancy: { enabled: true, tenants: { preferred: ['Private', 'Global'] } }, + }, + }, + { + // to make ignoreVersionMismatch setting work + // can be removed when we have corresponding ES version + dev: true, + } + ); + + console.log('Starting OpenSearchDashboards server..'); + await root.setup(); + await root.start(); + console.log('Started OpenSearchDashboards server'); + const createDataSource = await osdTestServer.request + .post(root, '/api/saved_objects/data-source') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send({ + attributes: { + title: 'test', + description: '', + endpoint: 'https://localhost:9202', + auth: { + type: 'username_password', + credentials: { + username: 'admin', + password: 'myStrongPassword123!', + }, + }, + }, + }); + expect(createDataSource.status).toEqual(200); + dataSourceId = createDataSource.body.id; + }); + + afterAll(async () => { + // shutdown OpenSearchDashboards server + await root.shutdown(); + }); + + it('delete cache', async () => { + const deleteCacheResponseWrongDataSource = await osdTestServer.request + .delete(root, '/api/v1/configuration/cache?dataSourceId=test') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + // Calling clear cache on a datasource that does not exist + expect(deleteCacheResponseWrongDataSource.status).not.toEqual(200); + expect(deleteCacheResponseWrongDataSource.text).toContain( + 'Data Source Error: Saved object [data-source/test] not found' + ); + + const deleteCacheResponseEmptyDataSource = await osdTestServer.request + .delete(root, '/api/v1/configuration/cache') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + // Calling clear cache on an empty datasource calls local cluster + expect(deleteCacheResponseEmptyDataSource.status).toEqual(200); + + const deleteCacheResponseRemoteDataSource = await osdTestServer.request + .delete(root, `/api/v1/configuration/cache?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + // Calling clear cache on an empty datasource calls local cluster + expect(deleteCacheResponseRemoteDataSource.status).toEqual(200); + }); + + it('Gets auth page correctly', async () => { + const getAuthResponseWrongDataSource = await osdTestServer.request + .get(root, '/api/v1/configuration/securityconfig?dataSourceId=test') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + // Getting auth info on a datasource that does not exist + expect(getAuthResponseWrongDataSource.status).not.toEqual(200); + expect(getAuthResponseWrongDataSource.text).toContain( + 'Data Source Error: Saved object [data-source/test] not found' + ); + + const getAuthResponseEmptyDataSource = await osdTestServer.request + .get(root, '/api/v1/configuration/securityconfig?dataSourceId=') + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + // Getting auth info on an empty datasource calls local cluster + expect(getAuthResponseEmptyDataSource.status).toEqual(200); + + const getAuthResponseRemoteDataSource = await osdTestServer.request + .get(root, `/api/v1/configuration/securityconfig?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + // Getting auth info on an empty datasource calls local cluster + expect(getAuthResponseRemoteDataSource.status).toEqual(200); + }); + + it('create/get/update/list/delete internal user for external datasource', async () => { + const testUsername = `test_user_${Date.now()}`; + const testUserPassword = 'testUserPassword123'; + const entityType = 'internalusers'; + + const createUserResponse = await createOrUpdateEntityAsAdminWithDataSource( + root, + entityType, + testUsername, + { + description: 'test user description', + password: testUserPassword, + backend_roles: ['arbitrary_backend_role'], + }, + dataSourceId + ); + expect(createUserResponse.status).toEqual(200); + + const getUserResponse = await getEntityAsAdminWithDataSource( + root, + entityType, + testUsername, + dataSourceId + ); + expect(getUserResponse.status).toEqual(200); + expect(getUserResponse.body.description).toEqual('test user description'); + expect(getUserResponse.body.backend_roles).toContain('arbitrary_backend_role'); + + const listUserResponse = await getAllEntitiesAsAdminWithDataSource( + root, + entityType, + dataSourceId + ); + expect(listUserResponse.status).toEqual(200); + expect(listUserResponse.body.total).toBeGreaterThan(2); + expect(listUserResponse.body.data[testUsername]).toBeTruthy(); + + const updateUserResponse = await createOrUpdateEntityAsAdminWithDataSource( + root, + entityType, + testUsername, + { + description: 'new description', + password: testUserPassword, + backend_roles: ['arbitrary_backend_role'], + }, + dataSourceId + ); + expect(updateUserResponse.status).toEqual(200); + + const getUpdatedUserResponse = await getEntityAsAdminWithDataSource( + root, + entityType, + testUsername, + dataSourceId + ); + expect(getUpdatedUserResponse.status).toEqual(200); + expect(getUpdatedUserResponse.body.description).toEqual('new description'); + + const deleteUserResponse = await deleteEntityAsAdminWithDataSource( + root, + entityType, + testUsername, + dataSourceId + ); + expect(deleteUserResponse.status).toEqual(200); + + const getDeletedUserResponse = await getEntityAsAdminWithDataSource( + root, + entityType, + testUsername, + dataSourceId + ); + expect(getDeletedUserResponse.status).toEqual(404); + }); + + it('CRUD Permissions for external datasource', async () => { + const entityType = 'actiongroups'; + const testActionGroupName = `test_action_group_${Date.now()}`; + + const createActionGroupResponse = await createOrUpdateEntityAsAdminWithDataSource( + root, + entityType, + testActionGroupName, + { + allowed_actions: ['some_allowed_action'], + }, + dataSourceId + ); + expect(createActionGroupResponse.status).toEqual(200); + + const getActionGroupsResponse = await getAllEntitiesAsAdminWithDataSource( + root, + entityType, + dataSourceId + ); + expect(getActionGroupsResponse.status).toEqual(200); + expect(getActionGroupsResponse.body.data?.hasOwnProperty(testActionGroupName)).toBe(true); + expect(getActionGroupsResponse.body.data[testActionGroupName].allowed_actions).toContain( + 'some_allowed_action' + ); + + // verify that this AG is not created in Local Cluster + const getActionGroupsResponseLocalCluster = await getAllEntitiesAsAdminWithDataSource( + root, + entityType, + '' + ); + expect(getActionGroupsResponseLocalCluster.status).toEqual(200); + expect(getActionGroupsResponseLocalCluster.body.data?.hasOwnProperty(testActionGroupName)).toBe( + false + ); + + const updatePermissionResponse = await createOrUpdateEntityAsAdminWithDataSource( + root, + entityType, + testActionGroupName, + { + allowed_actions: ['some_allowed_action', 'another_permission'], + }, + dataSourceId + ); + expect(updatePermissionResponse.status).toEqual(200); + + const getUpdatedActionGroupsResponse = await getAllEntitiesAsAdminWithDataSource( + root, + entityType, + dataSourceId + ); + expect(getUpdatedActionGroupsResponse.status).toEqual(200); + expect(getUpdatedActionGroupsResponse.body.data?.hasOwnProperty(testActionGroupName)).toBe( + true + ); + expect(getUpdatedActionGroupsResponse.body.data[testActionGroupName].allowed_actions).toContain( + 'another_permission' + ); + + const deleteActionGroupResponse = await deleteEntityAsAdminWithDataSource( + root, + entityType, + testActionGroupName, + dataSourceId + ); + expect(deleteActionGroupResponse.status).toEqual(200); + + const getDeletedActionGroupsResponse = await getAllEntitiesAsAdminWithDataSource( + root, + entityType, + dataSourceId + ); + expect(getDeletedActionGroupsResponse.status).toEqual(200); + expect(getDeletedActionGroupsResponse.body.data?.hasOwnProperty(testActionGroupName)).toBe( + false + ); + }); + + // Skip tenant multi datasource tests since it is locked to local cluster + + it('Audit logging', async () => { + const getAuditLoggingResponseRemote = await osdTestServer.request + .get(root, `/api/v1/configuration/audit?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + expect(getAuditLoggingResponseRemote.status).toEqual(200); + + const changeAuditLogResponseRemote = await osdTestServer.request + .post(root, `/api/v1/configuration/audit/config?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send(testAuditLogDisabledSettings); + expect(changeAuditLogResponseRemote.status).toEqual(200); + + const getAuditLoggingResponse = await osdTestServer.request + .get(root, `/api/v1/configuration/audit`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + expect(getAuditLoggingResponse.status).toEqual(200); + expect(getAuditLoggingResponse.body.config.enabled).toEqual(true); + + const checkAuditLogSettingsRemote = await osdTestServer.request + .get(root, `/api/v1/configuration/audit?dataSourceId=${dataSourceId}`) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + expect(checkAuditLogSettingsRemote.status).toEqual(200); + expect(checkAuditLogSettingsRemote.body.config.enabled).toEqual(false); + }); + + it('CRUD Roles for external datasource', async () => { + const rolesEntity = 'roles'; + const rolesMappingEntity = 'rolesmapping'; + const testRoleName = `test_role_${Date.now()}`; + + const payload = { + cluster_permissions: ['cluster_manage_pipelines'], + index_permissions: [ + { + index_patterns: ['*'], + dls: '', + fls: [], + masked_fields: [], + allowed_actions: ['data_access'], + }, + ], + tenant_permissions: [ + { + tenant_patterns: ['global_tenant'], + allowed_actions: ['kibana_all_write'], + }, + ], + }; + + const createRoleRespone = await createOrUpdateEntityAsAdminWithDataSource( + root, + rolesEntity, + testRoleName, + payload, + dataSourceId + ); + expect(createRoleRespone.status).toEqual(200); + + const getRolesResponse = await getAllEntitiesAsAdminWithDataSource( + root, + rolesEntity, + dataSourceId + ); + expect(getRolesResponse.status).toEqual(200); + expect(getRolesResponse.body.data?.hasOwnProperty(testRoleName)).toBe(true); + expect(getRolesResponse.body.data[testRoleName].cluster_permissions).toContain( + 'cluster_manage_pipelines' + ); + + // verify that this AG is not created in Local Cluster + const getRolesResponseLocalCluster = await getAllEntitiesAsAdminWithDataSource( + root, + rolesEntity, + '' + ); + expect(getRolesResponseLocalCluster.status).toEqual(200); + expect(getRolesResponseLocalCluster.body.data?.hasOwnProperty(testRoleName)).toBe(false); + + // Update + const updatePayload = { + cluster_permissions: ['cluster_manage_pipelines', 'manage_snapshots'], + index_permissions: [ + { + index_patterns: ['*'], + dls: '', + fls: [], + masked_fields: [], + allowed_actions: ['data_access'], + }, + ], + tenant_permissions: [ + { + tenant_patterns: ['global_tenant'], + allowed_actions: ['kibana_all_write'], + }, + ], + }; + const updateRoleResponse = await createOrUpdateEntityAsAdminWithDataSource( + root, + rolesEntity, + testRoleName, + updatePayload, + dataSourceId + ); + expect(updateRoleResponse.status).toEqual(200); + + const getUpdatedRoleResponse = await getAllEntitiesAsAdminWithDataSource( + root, + rolesEntity, + dataSourceId + ); + expect(getUpdatedRoleResponse.status).toEqual(200); + expect(getUpdatedRoleResponse.body.data?.hasOwnProperty(testRoleName)).toBe(true); + expect(getUpdatedRoleResponse.body.data[testRoleName].cluster_permissions).toContain( + 'manage_snapshots' + ); + + // update RoleMapping + const getRoleMappingResponse = await getEntityAsAdminWithDataSource( + root, + rolesMappingEntity, + testRoleName, + dataSourceId + ); + expect(getRoleMappingResponse.status).toEqual(404); // no mapping at first + + const rolesMappingPayload = { + users: ['admin'], + backend_roles: [], + hosts: [], + }; + + const createRoleMappingResponse = await createOrUpdateEntityAsAdminWithDataSource( + root, + rolesMappingEntity, + testRoleName, + rolesMappingPayload, + dataSourceId + ); + expect(createRoleMappingResponse.status).toEqual(200); + + const getUpdatedRoleMappingResponse = await getEntityAsAdminWithDataSource( + root, + rolesMappingEntity, + testRoleName, + dataSourceId + ); + expect(getUpdatedRoleMappingResponse.status).toEqual(200); + expect(getUpdatedRoleMappingResponse.body.users).toContain('admin'); + + // delete RoleMapping + const getRolesMappingToDelete = await getEntityAsAdminWithDataSource( + root, + rolesMappingEntity, + testRoleName, + dataSourceId + ); + expect(getRolesMappingToDelete.status).toEqual(200); + + const deleteRoleMapping = await deleteEntityAsAdminWithDataSource( + root, + rolesMappingEntity, + testRoleName, + dataSourceId + ); + expect(deleteRoleMapping.status).toEqual(200); + + const getRoleMappingUpdated = await getEntityAsAdminWithDataSource( + root, + rolesMappingEntity, + testRoleName, + dataSourceId + ); + expect(getRoleMappingUpdated.status).toEqual(404); // no mapping after first + + // Delete + const deleteRolesResponse = await deleteEntityAsAdminWithDataSource( + root, + rolesEntity, + testRoleName, + dataSourceId + ); + expect(deleteRolesResponse.status).toEqual(200); + + const getDeletedRoleResponse = await getAllEntitiesAsAdminWithDataSource( + root, + rolesEntity, + dataSourceId + ); + expect(getDeletedRoleResponse.status).toEqual(200); + expect(getDeletedRoleResponse.body.data?.hasOwnProperty(testRoleName)).toBe(false); + }); });