diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index b9a2096c9..e75f76792 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "3.0.0.0", "opensearchDashboardsVersion": "3.0.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..2379275e2 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, @@ -37,6 +37,7 @@ function tenantSpecifiedInUrl() { export async function setupTopNavButton(coreStart: CoreStart, config: ClientConfigType) { const accountInfo = (await fetchAccountInfoSafe(coreStart.http))?.data; + const currentTenant = await fetchCurrentTenant(coreStart.http); if (accountInfo) { // Missing role error if (accountInfo.roles.length === 0 && !window.location.href.includes(CUSTOM_ERROR_PAGE_URI)) { @@ -44,7 +45,12 @@ 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.enable_aggregation_view) { + tenant = currentTenant; + } else { + tenant = accountInfo.user_requested_tenant; + } let shouldShowTenantPopup = true; if (tenantSpecifiedInUrl() || getShouldShowTenantPopup() === false) { @@ -67,7 +73,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..6f5c07f88 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'; @@ -91,7 +92,12 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { 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 +106,7 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { }; fetchData(); - }, [props.coreStart.http]); + }, [props.coreStart.http, props.tenant, props.config.multitenancy.enable_aggregation_view]); // 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..9c6068d35 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="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..e9272597c 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="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..78a0dbd1c 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="tenant-permission-box" /> 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.tsx similarity index 74% rename from public/plugin.ts rename to public/plugin.tsx index 1ecf452fb..54c7ecc10 100644 --- a/public/plugin.ts +++ b/public/plugin.tsx @@ -14,6 +14,14 @@ */ import { BehaviorSubject } from 'rxjs'; +import { + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from 'src/plugins/saved_objects_management/public'; +import { EuiTableFieldDataColumnType } from '@elastic/eui'; +import { string } from 'joi'; +import React from 'react'; +import { i18n } from '@osd/i18n'; import { AppMountParameters, AppStatus, @@ -37,10 +45,11 @@ 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'; @@ -63,11 +72,21 @@ const APP_ID_DASHBOARDS = 'dashboards'; 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 { +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 +112,7 @@ export class SecurityPlugin implements PluginTenant, + dataType: 'string', + render: (value: any[][]) => { + let text = value[0][0]; + if (text === null || text === '') { + text = 'Global'; + } else if (text.startsWith('__user__')) { + text = 'Private'; + } + text = i18n.translate('savedObjectsManagement.objectsTable.table.columnTenantName', { + defaultMessage: text, + }); + return
{text}
; + }, + }, + loadData: () => {}, + } as unknown) as SavedObjectsManagementColumn); + } + // 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); @@ -157,6 +200,11 @@ export class SecurityPlugin implements Plugin 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 @@ -72,6 +91,7 @@ export abstract class AuthenticationType implements IAuthenticationType { ) { this.securityClient = new SecurityClient(esClient); this.type = ''; + this.config = config; } public authHandler: AuthenticationHandler = async (request, response, toolkit) => { @@ -80,6 +100,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 @@ -157,8 +179,16 @@ 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.enable_aggregation_view) { + const globalTenant = ''; + // Store all saved objects in a single kibana index. + Object.assign(authHeaders, { securitytenant: globalTenant }); + } else { + Object.assign(authHeaders, { securitytenant: tenant }); + } // set tenant to cookie if (tenant !== cookie!.tenant) { @@ -177,9 +207,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, }); }; 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/plugin.ts b/server/plugin.ts index 8c04eb275..e5fa06fe9 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -15,6 +15,7 @@ import { first } from 'rxjs/operators'; import { Observable } from 'rxjs'; +import _ from 'lodash'; import { PluginInitializerContext, CoreSetup, @@ -38,11 +39,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 +78,11 @@ export class SecurityPlugin implements Plugin(); const config = await config$.pipe(first()).toPromise(); if (config.multitenancy?.enabled) { @@ -161,6 +180,7 @@ export class SecurityPlugin implements Plugin { + const state: OpenSearchDashboardsAuthState = + (this.httpStart!.auth.get(wrapperOptions.request).state as OpenSearchDashboardsAuthState) || + {}; + + const createWithNamespace = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + username; + } + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.create(type, attributes, options); + }; + + const bulkGetWithNamespace = async ( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + 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'); + availableTenantNames.push(''); + availableTenantNames.push('__user__' + state.authInfo?.user_name); + _.assign(options, { namespaces: availableTenantNames }); + return await wrapperOptions.client.find(options); + }; + + const getWithNamespace = async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + 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> => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + username; + } + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkCreateWithNamespace = async ( + objects: Array>, + options?: SavedObjectsCreateOptions + ): Promise> => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + username; + } + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkCreate(objects, options); + }; + + const bulkUpdateWithNamespace = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + username; + } + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + + const deleteWithNamespace = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + username; + } + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.delete(type, id, options); + }; + + const checkConflictsWithNamespace = async ( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise => { + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + let namespaceValue = selectedTenant; + if (selectedTenant === '__user__') { + namespaceValue = selectedTenant + 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, + }; + }; +}