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,
+ };
+ };
+}