From ccb3ccd4074275e25d4fa373e0315e8434774976 Mon Sep 17 00:00:00 2001 From: Chang Liu Date: Thu, 3 Nov 2022 08:21:58 -0700 Subject: [PATCH] [Backport 2.x] OSD Saved Object Aggregation View (#1186) * Saved Object Aggregation View (#1146) * Move tenant-related utils to common folder (#1184) * [Saved Object Aggregation View] Use namespace registry to add tenant filter (#1169) Signed-off-by: Chang Liu Signed-off-by: Ryan Liang Signed-off-by: Craig Perkins Signed-off-by: Yan Zeng Co-authored-by: Ryan Liang Co-authored-by: Ryan Liang <109499885+RyanL1997@users.noreply.github.com> Co-authored-by: Craig Perkins Co-authored-by: Yan Zeng --- .github/workflows/cypress-test.yml | 131 ++++++++++ common/index.ts | 19 ++ opensearch_dashboards.json | 2 +- public/apps/account/account-app.tsx | 15 +- public/apps/account/account-nav-button.tsx | 3 +- public/apps/account/tenant-switch-panel.tsx | 11 +- public/apps/account/test/account-app.test.tsx | 18 +- .../apps/configuration/configuration-app.tsx | 4 +- .../role-edit/cluster-permission-panel.tsx | 1 + .../role-edit/index-permission-panel.tsx | 18 +- .../panels/role-edit/tenant-panel.tsx | 1 + .../apps/configuration/utils/tenant-utils.tsx | 82 ++++++- .../utils/test/tenant-utils.test.tsx | 62 ++++- public/apps/types.ts | 4 +- public/plugin.ts | 40 ++- public/types.ts | 12 +- server/auth/types/authentication_type.ts | 43 +++- server/index.ts | 1 + server/multitenancy/tenant_resolver.ts | 4 +- server/plugin.ts | 23 +- server/saved_objects/saved_objects_wrapper.ts | 227 ++++++++++++++++++ 21 files changed, 671 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/cypress-test.yml create mode 100644 server/saved_objects/saved_objects_wrapper.ts diff --git a/.github/workflows/cypress-test.yml b/.github/workflows/cypress-test.yml new file mode 100644 index 000000000..5ee064d53 --- /dev/null +++ b/.github/workflows/cypress-test.yml @@ -0,0 +1,131 @@ +name: Cypress Tests + +on: [push, pull_request] + +env: + TEST_BROWSER_HEADLESS: 1 + CI: 1 + FTR_PATH: 'ftr' + START_CMD: 'node ../scripts/opensearch_dashboards --dev --no-base-path --no-watch --opensearch_security.multitenancy.enable_aggregation_view=true' + OPENSEARCH_SNAPSHOT_CMD: 'node ../scripts/opensearch snapshot' + SPEC: 'cypress/integration/plugins/security-dashboards-plugin/aggregation_view.js,' + +jobs: + tests: + name: Run aggregation view cypress test + runs-on: ubuntu-latest + steps: + - name: Download OpenSearch Core + run: | + wget https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/2.4.0/latest/linux/x64/tar/builds/opensearch/dist/opensearch-min-2.4.0-linux-x64.tar.gz + tar -xzf opensearch-*.tar.gz + rm -f opensearch-*.tar.gz + + - name: Download OpenSearch Security Plugin + run: wget -O opensearch-security.zip https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/2.4.0/latest/linux/x64/tar/builds/opensearch/plugins/opensearch-security-2.4.0.0.zip + + + - name: Run OpenSearch with plugin + run: | + cat > os-ep.sh <> /opensearch/config/opensearch.yml + chown 1001:1001 -R /opensearch + su -c "/opensearch/bin/opensearch" -s /bin/bash opensearch + EOF + docker build -t opensearch-test:latest -f- . <> ./config/opensearch_dashboards.yml + echo 'opensearch.hosts: ["https://localhost:9200"]' >> ./config/opensearch_dashboards.yml + echo 'opensearch.ssl.verificationMode: none' >> ./config/opensearch_dashboards.yml + echo 'opensearch.username: "kibanaserver"' >> ./config/opensearch_dashboards.yml + echo 'opensearch.password: "kibanaserver"' >> ./config/opensearch_dashboards.yml + echo 'opensearch.requestHeadersWhitelist: [ authorization,securitytenant ]' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.multitenancy.enabled: true' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.multitenancy.tenants.preferred: ["Private", "Global"]' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.readonly_mode.roles: ["kibana_read_only"]' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.cookie.secure: false' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.multitenancy.enable_aggregation_view: true' >> ./config/opensearch_dashboards.yml + yarn start --no-base-path --no-watch & + sleep 300 + + - name: Checkout + uses: actions/checkout@v2 + with: + path: ${{ env.FTR_PATH }} + repository: opensearch-project/opensearch-dashboards-functional-test + ref: '2.x' + + - name: Get Cypress version + id: cypress_version + run: | + echo "::set-output name=cypress_version::$(cat ./${{ env.FTR_PATH }}/package.json | jq '.devDependencies.cypress' | tr -d '"')" + + - name: Run tests + uses: cypress-io/github-action@v2 + with: + working-directory: ${{ env.FTR_PATH }} + command: yarn cypress:run-with-security-and-aggregation-view --browser chromium --spec ${{ env.SPEC }} diff --git a/common/index.ts b/common/index.ts index b820a3992..535d94972 100644 --- a/common/index.ts +++ b/common/index.ts @@ -29,6 +29,13 @@ export const API_AUTH_LOGOUT = '/auth/logout'; export const ERROR_MISSING_ROLE_PATH = '/missing-role'; +export const GLOBAL_TENANT_SYMBOL = ''; +export const PRIVATE_TENANT_SYMBOL = '__user__'; +export const DEFAULT_TENANT = 'default'; +export const GLOBAL_TENANT_RENDERING_TEXT = 'Global'; +export const PRIVATE_TENANT_RENDERING_TEXT = 'Private'; +export const globalTenantName = 'global_tenant'; + export enum AuthType { BASIC = 'basicauth', OPEN_ID = 'openid', @@ -47,3 +54,15 @@ export function isValidResourceName(resourceName: string): boolean { const exp = new RegExp('[\\p{C}%]', 'u'); return !exp.test(resourceName) && resourceName.length > 0; } + +export function isPrivateTenant(selectedTenant: string | null) { + return selectedTenant !== null && selectedTenant === PRIVATE_TENANT_SYMBOL; +} + +export function isRenderingPrivateTenant(selectedTenant: string | null) { + return selectedTenant !== null && selectedTenant?.startsWith(PRIVATE_TENANT_SYMBOL); +} + +export function isGlobalTenant(selectedTenant: string | null) { + return selectedTenant !== null && selectedTenant === GLOBAL_TENANT_SYMBOL; +} diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 4dfde6af0..8966496d2 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "2.4.0.0", "opensearchDashboardsVersion": "2.4.0", "configPath": ["opensearch_security"], - "requiredPlugins": ["navigation"], + "requiredPlugins": ["navigation", "savedObjectsManagement"], "server": true, "ui": true } \ No newline at end of file diff --git a/public/apps/account/account-app.tsx b/public/apps/account/account-app.tsx index 0ceb8f5e3..590d827ea 100644 --- a/public/apps/account/account-app.tsx +++ b/public/apps/account/account-app.tsx @@ -20,7 +20,7 @@ import { AccountNavButton } from './account-nav-button'; import { fetchAccountInfoSafe } from './utils'; import { ClientConfigType } from '../../types'; import { CUSTOM_ERROR_PAGE_URI, ERROR_MISSING_ROLE_PATH } from '../../../common'; -import { selectTenant } from '../configuration/utils/tenant-utils'; +import { fetchCurrentTenant, selectTenant } from '../configuration/utils/tenant-utils'; import { getSavedTenant, getShouldShowTenantPopup, @@ -44,7 +44,16 @@ export async function setupTopNavButton(coreStart: CoreStart, config: ClientConf coreStart.http.basePath.serverBasePath + CUSTOM_ERROR_PAGE_URI + ERROR_MISSING_ROLE_PATH; } - let tenant = accountInfo.user_requested_tenant; + let tenant: string | undefined; + if (config.multitenancy.enabled) { + try { + tenant = await fetchCurrentTenant(coreStart.http); + } catch (e) { + tenant = undefined; + console.log(e); + } + } + let shouldShowTenantPopup = true; if (tenantSpecifiedInUrl() || getShouldShowTenantPopup() === false) { @@ -67,7 +76,7 @@ export async function setupTopNavButton(coreStart: CoreStart, config: ClientConf window.location.reload(); } } - } catch (e) { + } catch (e: any) { constructErrorMessageAndLog(e, `Failed to switch to ${tenant} tenant.`); } } diff --git a/public/apps/account/account-nav-button.tsx b/public/apps/account/account-nav-button.tsx index 7bd0e578b..1ca3360b1 100644 --- a/public/apps/account/account-nav-button.tsx +++ b/public/apps/account/account-nav-button.tsx @@ -61,9 +61,10 @@ export function AccountNavButton(props: { setModal(null); window.location.reload(); }} + tenant={props.tenant!} /> ), - [props.config, props.coreStart] + [props.config, props.coreStart, props.tenant] ); // Check if the tenant modal should be shown on load diff --git a/public/apps/account/tenant-switch-panel.tsx b/public/apps/account/tenant-switch-panel.tsx index 079332015..96d5b9659 100755 --- a/public/apps/account/tenant-switch-panel.tsx +++ b/public/apps/account/tenant-switch-panel.tsx @@ -48,6 +48,7 @@ interface TenantSwitchPanelProps { handleClose: () => void; handleSwitchAndClose: () => void; config: ClientConfigType; + tenant: string; } const GLOBAL_TENANT_KEY_NAME = 'global_tenant'; @@ -90,8 +91,12 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { const currentUserName = accountInfo.data.user_name; setUsername(currentUserName); - // @ts-ignore - const currentRawTenantName = accountInfo.data.user_requested_tenant; + let currentRawTenantName: string | undefined; + if (props.config.multitenancy.enable_aggregation_view) { + currentRawTenantName = props.tenant; + } else { + currentRawTenantName = accountInfo.data.user_requested_tenant; + } setCurrentTenant(currentRawTenantName || '', currentUserName); } catch (e) { // TODO: switch to better error display. @@ -100,7 +105,7 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { }; fetchData(); - }, [props.coreStart.http]); + }, [props.coreStart.http, props.tenant, props.config.multitenancy]); // Custom tenant super select related. const onCustomTenantChange = (selectedOption: EuiComboBoxOptionOption[]) => { diff --git a/public/apps/account/test/account-app.test.tsx b/public/apps/account/test/account-app.test.tsx index fa5d45ba0..52282ab72 100644 --- a/public/apps/account/test/account-app.test.tsx +++ b/public/apps/account/test/account-app.test.tsx @@ -22,7 +22,7 @@ import { getSavedTenant, } from '../../../utils/storage-utils'; import { fetchAccountInfoSafe } from '../utils'; -import { selectTenant } from '../../configuration/utils/tenant-utils'; +import { fetchCurrentTenant, selectTenant } from '../../configuration/utils/tenant-utils'; jest.mock('../../../utils/storage-utils', () => ({ getShouldShowTenantPopup: jest.fn(), @@ -36,6 +36,7 @@ jest.mock('../utils', () => ({ jest.mock('../../configuration/utils/tenant-utils', () => ({ selectTenant: jest.fn(), + fetchCurrentTenant: jest.fn(), })); describe('Account app', () => { @@ -47,6 +48,12 @@ describe('Account app', () => { }, }; + const mockConfig = { + multitenancy: { + enable_aggregation_view: true, + }, + }; + const mockAccountInfo = { data: { roles: { @@ -55,8 +62,11 @@ describe('Account app', () => { }, }; + const mockTenant = 'test1'; + beforeAll(() => { (fetchAccountInfoSafe as jest.Mock).mockResolvedValue(mockAccountInfo); + (fetchCurrentTenant as jest.Mock).mockResolvedValue(mockTenant); }); it('Should skip if auto swich if securitytenant in url', (done) => { @@ -65,7 +75,7 @@ describe('Account app', () => { delete window.location; window.location = new URL('http://www.example.com?securitytenant=abc') as any; - setupTopNavButton(mockCoreStart, {} as any); + setupTopNavButton(mockCoreStart, mockConfig as any); process.nextTick(() => { expect(setShouldShowTenantPopup).toBeCalledWith(false); @@ -77,7 +87,7 @@ describe('Account app', () => { it('Should switch to saved tenant when securitytenant not in url', (done) => { (getSavedTenant as jest.Mock).mockReturnValueOnce('tenant1'); - setupTopNavButton(mockCoreStart, {} as any); + setupTopNavButton(mockCoreStart, mockConfig as any); process.nextTick(() => { expect(getSavedTenant).toBeCalledTimes(1); @@ -92,7 +102,7 @@ describe('Account app', () => { it('Should show tenant selection popup when neither securitytenant in url nor saved tenant', (done) => { (getSavedTenant as jest.Mock).mockReturnValueOnce(null); - setupTopNavButton(mockCoreStart, {} as any); + setupTopNavButton(mockCoreStart, mockConfig as any); process.nextTick(() => { expect(getSavedTenant).toBeCalledTimes(1); diff --git a/public/apps/configuration/configuration-app.tsx b/public/apps/configuration/configuration-app.tsx index 83ae62846..a2294315d 100644 --- a/public/apps/configuration/configuration-app.tsx +++ b/public/apps/configuration/configuration-app.tsx @@ -19,12 +19,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@osd/i18n/react'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; -import { AppPluginStartDependencies, ClientConfigType } from '../../types'; +import { SecurityPluginStartDependencies, ClientConfigType } from '../../types'; import { AppRouter } from './app-router'; export function renderApp( coreStart: CoreStart, - navigation: AppPluginStartDependencies, + navigation: SecurityPluginStartDependencies, params: AppMountParameters, config: ClientConfigType ) { diff --git a/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx b/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx index b6e1d4871..e80d0d0e1 100644 --- a/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx +++ b/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx @@ -49,6 +49,7 @@ export function ClusterPermissionPanel(props: { options={optionUniverse} selectedOptions={state} onChange={setState} + id="roles-cluster-permission-box" /> {/* TODO: 'Browse and select' button with a pop-up modal for selection */} 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 4c0e0ae7b..75e2856e4 100644 --- a/public/apps/configuration/panels/role-edit/index-permission-panel.tsx +++ b/public/apps/configuration/panels/role-edit/index-permission-panel.tsx @@ -119,13 +119,16 @@ export function IndexPatternRow(props: { }) { return ( - + + + ); } @@ -150,6 +153,7 @@ export function IndexPermissionRow(props: { options={props.permisionOptionsSet} selectedOptions={props.value} onChange={props.onChangeHandler} + id="roles-index-permission-box" /> {/* TODO: 'Browse and select' button with a pop-up modal for selection */} diff --git a/public/apps/configuration/panels/role-edit/tenant-panel.tsx b/public/apps/configuration/panels/role-edit/tenant-panel.tsx index a2c239755..2cce9e546 100644 --- a/public/apps/configuration/panels/role-edit/tenant-panel.tsx +++ b/public/apps/configuration/panels/role-edit/tenant-panel.tsx @@ -91,6 +91,7 @@ function generateTenantPermissionPanels( onChange={onValueChangeHandler('tenantPatterns')} onCreateOption={onCreateOptionHandler('tenantPatterns')} options={permisionOptionsSet} + id="roles-tenant-permission-box" /> diff --git a/public/apps/configuration/utils/tenant-utils.tsx b/public/apps/configuration/utils/tenant-utils.tsx index 080a93f1c..7abf132f2 100644 --- a/public/apps/configuration/utils/tenant-utils.tsx +++ b/public/apps/configuration/utils/tenant-utils.tsx @@ -15,30 +15,41 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { map } from 'lodash'; +import React from 'react'; +import { i18n } from '@osd/i18n'; import { - API_ENDPOINT_TENANTS, API_ENDPOINT_MULTITENANCY, + API_ENDPOINT_TENANTS, RoleViewTenantInvalidText, + TENANT_READ_PERMISSION, + TENANT_WRITE_PERMISSION, } from '../constants'; import { DataObject, ObjectsMessage, - Tenant, - TenantUpdate, - TenantSelect, - RoleTenantPermissionView, + RoleTenantPermission, RoleTenantPermissionDetail, + RoleTenantPermissionView, + Tenant, TenantPermissionType, - RoleTenantPermission, + TenantSelect, + TenantUpdate, } from '../types'; -import { TENANT_READ_PERMISSION, TENANT_WRITE_PERMISSION } from '../constants'; import { httpDelete, httpGet, httpPost } from './request-utils'; import { getResourceUrl } from './resource-utils'; +import { + DEFAULT_TENANT, + GLOBAL_TENANT_RENDERING_TEXT, + GLOBAL_TENANT_SYMBOL, + globalTenantName, + isGlobalTenant, + isRenderingPrivateTenant, + PRIVATE_TENANT_RENDERING_TEXT, +} from '../../../../common'; -export const globalTenantName = 'global_tenant'; export const GLOBAL_USER_DICT: { [key: string]: string } = { Label: 'Global', - Value: '', + Value: GLOBAL_TENANT_SYMBOL, Description: 'Everyone can see it', }; @@ -62,10 +73,10 @@ export function transformTenantData( ): Tenant[] { // @ts-ignore const tenantList: Tenant[] = map(rawTenantData, (v: Tenant, k?: string) => ({ - tenant: k === globalTenantName ? GLOBAL_USER_DICT.Label : k || '', + tenant: k === globalTenantName ? GLOBAL_USER_DICT.Label : k || GLOBAL_TENANT_SYMBOL, reserved: v.reserved, description: k === globalTenantName ? GLOBAL_USER_DICT.Description : v.description, - tenantValue: k === globalTenantName ? GLOBAL_USER_DICT.Value : k || '', + tenantValue: k === globalTenantName ? GLOBAL_USER_DICT.Value : k || GLOBAL_TENANT_SYMBOL, })); if (isPrivateEnabled) { // Insert Private Tenant in List @@ -170,3 +181,52 @@ export function transformRoleTenantPermissions( permissionType: getTenantPermissionType(tenantPermission.allowed_actions), })); } + +export function getNamespacesToRegister(accountInfo: any) { + const tenants = accountInfo.tenants || {}; + const availableTenantNames = Object.keys(tenants!); + const namespacesToRegister = availableTenantNames.map((tenant) => { + if (tenant === globalTenantName) { + return { + id: GLOBAL_USER_DICT.Value, + name: GLOBAL_USER_DICT.Label, + }; + } else if (tenant === accountInfo.user_name) { + return { + id: `${PRIVATE_USER_DICT.Value}${accountInfo.user_name}`, + name: PRIVATE_USER_DICT.Label, + }; + } + return { + id: tenant, + name: tenant, + }; + }); + namespacesToRegister.push({ + id: DEFAULT_TENANT, + name: DEFAULT_TENANT, + }); + return namespacesToRegister; +} + +export const tenantColumn = { + id: 'tenant_column', + euiColumn: { + field: 'namespaces', + name:
Tenant
, + dataType: 'string', + render: (value: any[][]) => { + let text = value.flat()[0]; + if (isGlobalTenant(text)) { + text = GLOBAL_TENANT_RENDERING_TEXT; + } else if (isRenderingPrivateTenant(text)) { + text = PRIVATE_TENANT_RENDERING_TEXT; + } + text = i18n.translate('savedObjectsManagement.objectsTable.table.columnTenantName', { + defaultMessage: text, + }); + return
{text}
; + }, + }, + loadData: () => {}, +}; diff --git a/public/apps/configuration/utils/test/tenant-utils.test.tsx b/public/apps/configuration/utils/test/tenant-utils.test.tsx index c079026b5..4d5c773d1 100644 --- a/public/apps/configuration/utils/test/tenant-utils.test.tsx +++ b/public/apps/configuration/utils/test/tenant-utils.test.tsx @@ -20,11 +20,11 @@ import { resolveTenantName, RESOLVED_GLOBAL_TENANT, RESOLVED_PRIVATE_TENANT, - globalTenantName, formatTenantName, transformRoleTenantPermissionData, getTenantPermissionType, transformRoleTenantPermissions, + getNamespacesToRegister, } from '../tenant-utils'; import { RoleViewTenantInvalidText, @@ -32,6 +32,7 @@ import { TENANT_WRITE_PERMISSION, } from '../../constants'; import { TenantPermissionType } from '../../types'; +import { globalTenantName } from '../../../../../common'; describe('Tenant list utils', () => { const expectedGlobalTenantListing = { @@ -282,4 +283,63 @@ describe('Tenant list utils', () => { expect(result[0]).toMatchObject(expectedRoleTenantPermissionView); }); }); + + describe('get list of namespaces to register', () => { + it('resolves to list of namespaces with a custom tenant', () => { + const authInfo = { + user_name: 'user1', + tenants: { + global_tenant: true, + user1_tenant: true, + user1: true, + }, + }; + const expectedNamespaces = [ + { + id: GLOBAL_USER_DICT.Value, + name: GLOBAL_USER_DICT.Label, + }, + { + id: 'user1_tenant', + name: 'user1_tenant', + }, + { + id: `${PRIVATE_USER_DICT.Value}user1`, + name: PRIVATE_USER_DICT.Label, + }, + { + id: 'default', + name: 'default', + }, + ]; + const result = getNamespacesToRegister(authInfo); + expect(result).toMatchObject(expectedNamespaces); + }); + + it('resolves to list of namespaces without a custom tenant', () => { + const authInfo = { + user_name: 'user1', + tenants: { + global_tenant: true, + user1: true, + }, + }; + const expectedNamespaces = [ + { + id: GLOBAL_USER_DICT.Value, + name: GLOBAL_USER_DICT.Label, + }, + { + id: `${PRIVATE_USER_DICT.Value}user1`, + name: PRIVATE_USER_DICT.Label, + }, + { + id: 'default', + name: 'default', + }, + ]; + const result = getNamespacesToRegister(authInfo); + expect(result).toMatchObject(expectedNamespaces); + }); + }); }); diff --git a/public/apps/types.ts b/public/apps/types.ts index 0ca39e1b0..3f5c870b0 100644 --- a/public/apps/types.ts +++ b/public/apps/types.ts @@ -14,11 +14,11 @@ */ import { AppMountParameters, CoreStart } from '../../../../src/core/public'; -import { AppPluginStartDependencies, ClientConfigType } from '../types'; +import { SecurityPluginStartDependencies, ClientConfigType } from '../types'; export interface AppDependencies { coreStart: CoreStart; - navigation: AppPluginStartDependencies; + navigation: SecurityPluginStartDependencies; params: AppMountParameters; config: ClientConfigType; } diff --git a/public/plugin.ts b/public/plugin.ts index 1ecf452fb..d8d39f5b7 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -14,6 +14,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import { SavedObjectsManagementColumn } from 'src/plugins/saved_objects_management/public'; import { AppMountParameters, AppStatus, @@ -37,13 +38,15 @@ import { excludeFromDisabledTransportCategories, } from './apps/configuration/panels/audit-logging/constants'; import { - AppPluginStartDependencies, + SecurityPluginStartDependencies, ClientConfigType, SecurityPluginSetup, SecurityPluginStart, + SecurityPluginSetupDependencies, } from './types'; import { addTenantToShareURL } from './services/shared-link'; import { interceptError } from './utils/logout-utils'; +import { tenantColumn, getNamespacesToRegister } from './apps/configuration/utils/tenant-utils'; async function hasApiPermission(core: CoreSetup): Promise { try { @@ -62,12 +65,24 @@ const APP_ID_DASHBOARDS = 'dashboards'; // OpenSearchDashboards app is for legacy url migration const APP_ID_OPENSEARCH_DASHBOARDS = 'kibana'; const APP_LIST_FOR_READONLY_ROLE = [APP_ID_HOME, APP_ID_DASHBOARDS, APP_ID_OPENSEARCH_DASHBOARDS]; - -export class SecurityPlugin implements Plugin { +const GLOBAL_TENANT_RENDERING_TEXT = 'Global'; +const PRIVATE_TENANT_RENDERING_TEXT = 'Private'; + +export class SecurityPlugin + implements + Plugin< + SecurityPluginSetup, + SecurityPluginStart, + SecurityPluginSetupDependencies, + SecurityPluginStartDependencies + > { // @ts-ignore : initializerContext not used constructor(private readonly initializerContext: PluginInitializerContext) {} - public async setup(core: CoreSetup): Promise { + public async setup( + core: CoreSetup, + deps: SecurityPluginSetupDependencies + ): Promise { const apiPermission = await hasApiPermission(core); const config = this.initializerContext.config.get(); @@ -93,7 +108,7 @@ export class SecurityPlugin implements Plugin + ); + if (!!accountInfo) { + const namespacesToRegister = getNamespacesToRegister(accountInfo); + deps.savedObjectsManagement.namespaces.registerAlias('Tenant'); + namespacesToRegister.forEach((ns) => { + deps.savedObjectsManagement.namespaces.register(ns as SavedObjectsManagementNamespace); + }); + } + } + // Return methods that should be available to other plugins return {}; } - public start(core: CoreStart): SecurityPluginStart { + public start(core: CoreStart, deps: SecurityPluginStartDependencies): SecurityPluginStart { const config = this.initializerContext.config.get(); setupTopNavButton(core, config); diff --git a/public/types.ts b/public/types.ts index 9e63ca632..8dd2ac2c4 100644 --- a/public/types.ts +++ b/public/types.ts @@ -14,14 +14,23 @@ */ import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; +import { + SavedObjectsManagementPluginSetup, + SavedObjectsManagementPluginStart, +} from '../../../src/plugins/saved_objects_management/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecurityPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecurityPluginStart {} -export interface AppPluginStartDependencies { +export interface SecurityPluginSetupDependencies { + savedObjectsManagement: SavedObjectsManagementPluginSetup; +} + +export interface SecurityPluginStartDependencies { navigation: NavigationPublicPluginStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; } export interface AuthInfo { @@ -49,6 +58,7 @@ export interface ClientConfigType { backend_configurable: boolean; }; multitenancy: { + enable_aggregation_view: boolean; enabled: boolean; tenants: { enable_private: boolean; diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index f495de706..b097435b8 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -31,6 +31,7 @@ import { SecuritySessionCookie } from '../../session/security_cookie'; import { SecurityClient } from '../../backend/opensearch_security_client'; import { resolveTenant, isValidTenant } from '../../multitenancy/tenant_resolver'; import { UnauthenticatedError } from '../../errors'; +import { GLOBAL_TENANT_SYMBOL } from '../../../common'; export interface IAuthenticationType { type: string; @@ -46,6 +47,25 @@ export type IAuthHandlerConstructor = new ( logger: Logger ) => IAuthenticationType; +export interface OpenSearchAuthInfo { + user: string; + user_name: string; + user_requested_tenant: string; + remote_address: string; + backend_roles: string[]; + custom_attribute_names: string[]; + roles: string[]; + tenants: Record; + principal: string | null; + peer_certificates: string | null; + sso_logout_url: string | null; +} + +export interface OpenSearchDashboardsAuthState { + authInfo?: OpenSearchAuthInfo; + selectedTenant?: string; +} + export abstract class AuthenticationType implements IAuthenticationType { protected static readonly ROUTES_TO_IGNORE: string[] = [ '/api/core/capabilities', // FIXME: need to figureout how to bypass this API call @@ -68,6 +88,7 @@ export abstract class AuthenticationType implements IAuthenticationType { ) { this.securityClient = new SecurityClient(esClient); this.type = ''; + this.config = config; } public authHandler: AuthenticationHandler = async (request, response, toolkit) => { @@ -76,6 +97,8 @@ export abstract class AuthenticationType implements IAuthenticationType { return toolkit.authenticated(); } + const authState: OpenSearchDashboardsAuthState = {}; + // if browser request, auth logic is: // 1. check if request includes auth header or paramter(e.g. jwt in url params) is present, if so, authenticate with auth header. // 2. if auth header not present, check if auth cookie is present, if no cookie, send to authentication workflow @@ -100,7 +123,7 @@ export abstract class AuthenticationType implements IAuthenticationType { } this.sessionStorageFactory.asScoped(request).set(cookie); - } catch (error) { + } catch (error: any) { return response.unauthorized({ body: error.message, }); @@ -109,7 +132,7 @@ export abstract class AuthenticationType implements IAuthenticationType { // no auth header in request, try cookie try { cookie = await this.sessionStorageFactory.asScoped(request).get(); - } catch (error) { + } catch (error: any) { this.logger.error(`Error parsing cookie: ${error.message}`); cookie = undefined; } @@ -153,8 +176,15 @@ export abstract class AuthenticationType implements IAuthenticationType { 'No available tenant for current user, please reach out to your system administrator', }); } + authState.selectedTenant = tenant; + // set tenant in header - Object.assign(authHeaders, { securitytenant: tenant }); + if (this.config.multitenancy.enabled && this.config.multitenancy.enable_aggregation_view) { + // Store all saved objects in a single kibana index. + Object.assign(authHeaders, { securitytenant: GLOBAL_TENANT_SYMBOL }); + } else { + Object.assign(authHeaders, { securitytenant: tenant }); + } // set tenant to cookie if (tenant !== cookie!.tenant) { @@ -173,9 +203,14 @@ export abstract class AuthenticationType implements IAuthenticationType { throw error; } } + if (!authInfo) { + authInfo = await this.securityClient.authinfo(request, authHeaders); + } + authState.authInfo = authInfo; return toolkit.authenticated({ requestHeaders: authHeaders, + state: authState, }); }; @@ -205,7 +240,7 @@ export abstract class AuthenticationType implements IAuthenticationType { if (!authInfo) { try { authInfo = await this.securityClient.authinfo(request, authHeader); - } catch (error) { + } catch (error: any) { throw new UnauthenticatedError(error); } } diff --git a/server/index.ts b/server/index.ts index bf1a2699d..99adb015b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -104,6 +104,7 @@ export const configSchema = schema.object({ show_roles: schema.boolean({ defaultValue: false }), enable_filter: schema.boolean({ defaultValue: false }), debug: schema.boolean({ defaultValue: false }), + enable_aggregation_view: schema.boolean({ defaultValue: false }), tenants: schema.object({ enable_private: schema.boolean({ defaultValue: true }), enable_global: schema.boolean({ defaultValue: true }), diff --git a/server/multitenancy/tenant_resolver.ts b/server/multitenancy/tenant_resolver.ts index 90b7c0ff3..9a2ad989d 100755 --- a/server/multitenancy/tenant_resolver.ts +++ b/server/multitenancy/tenant_resolver.ts @@ -17,9 +17,7 @@ import { isEmpty, findKey, cloneDeep } from 'lodash'; import { OpenSearchDashboardsRequest } from '../../../../src/core/server'; import { SecuritySessionCookie } from '../session/security_cookie'; import { SecurityPluginConfigType } from '..'; - -const PRIVATE_TENANT_SYMBOL: string = '__user__'; -const GLOBAL_TENANT_SYMBOL: string = ''; +import { GLOBAL_TENANT_SYMBOL, PRIVATE_TENANT_SYMBOL } from '../../common'; export const PRIVATE_TENANTS: string[] = [PRIVATE_TENANT_SYMBOL, 'private']; export const GLOBAL_TENANTS: string[] = ['global', GLOBAL_TENANT_SYMBOL]; diff --git a/server/plugin.ts b/server/plugin.ts index 8c04eb275..b7c9e0ce8 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -38,11 +38,15 @@ import { ISavedObjectTypeRegistry, } from '../../../src/core/server/saved_objects'; import { setupIndexTemplate, migrateTenantIndices } from './multitenancy/tenant_index'; -import { IAuthenticationType } from './auth/types/authentication_type'; +import { + IAuthenticationType, + OpenSearchDashboardsAuthState, +} from './auth/types/authentication_type'; import { getAuthenticationHandler } from './auth/auth_handler_factory'; import { setupMultitenantRoutes } from './multitenancy/routes'; import { defineAuthTypeRoutes } from './routes/auth_type_routes'; import { createMigrationOpenSearchClient } from '../../../src/core/server/saved_objects/migrations/core'; +import { SecuritySavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper'; export interface SecurityPluginRequestContext { logger: Logger; @@ -73,8 +77,11 @@ export class SecurityPlugin implements Plugin(); const config = await config$.pipe(first()).toPromise(); + + this.savedObjectClientWrapper.httpStart = core.http; + this.savedObjectClientWrapper.config = config; + if (config.multitenancy?.enabled) { const globalConfig$: Observable = this.initializerContext.config.legacy .globalConfig$; @@ -161,6 +181,7 @@ export class SecurityPlugin implements Plugin { + const state: OpenSearchDashboardsAuthState = + (this.httpStart!.auth.get(wrapperOptions.request).state as OpenSearchDashboardsAuthState) || + {}; + + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + const isGlobalEnabled = this.config!.multitenancy.tenants.enable_global; + const isPrivateEnabled = this.config!.multitenancy.tenants.enable_private; + + let namespaceValue = selectedTenant; + + const createWithNamespace = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.create(type, attributes, options); + }; + + const bulkGetWithNamespace = async ( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkGet(objects, options); + }; + + const findWithNamespace = async ( + options: SavedObjectsFindOptions + ): Promise> => { + const tenants = state.authInfo?.tenants; + const availableTenantNames = Object.keys(tenants!); + availableTenantNames.push(DEFAULT_TENANT); // The value of namespace is "default" if saved objects are created when opensearch_security.multitenancy.enable_aggregation_view is set to false. So adding it to find. + if (isGlobalEnabled) { + availableTenantNames.push(GLOBAL_TENANT_SYMBOL); + } + if (isPrivateEnabled) { + availableTenantNames.push(PRIVATE_TENANT_SYMBOL + username); + } + if (availableTenantNames.includes(globalTenantName)) { + let index = availableTenantNames.indexOf(globalTenantName); + if (index > -1) { + availableTenantNames.splice(index, 1); + } + index = availableTenantNames.indexOf(username!); + if (index > -1) { + availableTenantNames.splice(index, 1); + } + } + if (isPrivateTenant(selectedTenant!)) { + namespaceValue = selectedTenant! + username; + } + if (!!options.namespaces) { + const namespacesToInclude = Array.isArray(options.namespaces) + ? options.namespaces + : [options.namespaces]; + const typeToNamespacesMap: any = {}; + const searchTypes = Array.isArray(options.type) ? options.type : [options.type]; + searchTypes.forEach((t) => { + typeToNamespacesMap[t] = namespacesToInclude; + }); + if (searchTypes.includes('config')) { + if (namespacesToInclude.includes(namespaceValue)) { + typeToNamespacesMap.config = [namespaceValue]; + } else { + delete typeToNamespacesMap.config; + } + } + + options.typeToNamespacesMap = new Map(Object.entries(typeToNamespacesMap)); + options.type = ''; + options.namespaces = []; + } else { + options.namespaces = [namespaceValue]; + } + + return await wrapperOptions.client.find(options); + }; + + const getWithNamespace = async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.get(type, id, options); + }; + + const updateWithNamespace = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkCreateWithNamespace = async ( + objects: Array>, + options?: SavedObjectsCreateOptions + ): Promise> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkCreate(objects, options); + }; + + const bulkUpdateWithNamespace = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + + const deleteWithNamespace = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.delete(type, id, options); + }; + + const checkConflictsWithNamespace = async ( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.checkConflicts(objects, options); + }; + + return { + ...wrapperOptions.client, + get: getWithNamespace, + update: updateWithNamespace, + bulkCreate: bulkCreateWithNamespace, + bulkGet: bulkGetWithNamespace, + bulkUpdate: bulkUpdateWithNamespace, + create: createWithNamespace, + delete: deleteWithNamespace, + errors: wrapperOptions.client.errors, + checkConflicts: checkConflictsWithNamespace, + addToNamespaces: wrapperOptions.client.addToNamespaces, + find: findWithNamespace, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; + }; + + private isAPrivateTenant(selectedTenant: string | undefined, isPrivateEnabled: boolean) { + return selectedTenant !== undefined && isPrivateEnabled && isPrivateTenant(selectedTenant); + } + + private getNamespaceValue( + selectedTenant: string | undefined, + isPrivateEnabled: boolean, + username: string | undefined + ) { + let namespaceValue = selectedTenant; + if (this.isAPrivateTenant(selectedTenant, isPrivateEnabled)) { + namespaceValue = selectedTenant! + username; + } + return namespaceValue; + } +}