diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 11e772087749..46a0c018c6ea 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -290,3 +290,8 @@ # Set the value to true to enable workspace feature # workspace.enabled: false + +# Set the backend roles in groups or users, whoever has the backend roles or exactly match the user ids defined in this config will be regard as dashboard admin. +# Dashboard admin will have the access to all the workspaces(workspace.enabled: true) and objects inside OpenSearch Dashboards. +# opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"] +# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"] \ No newline at end of file diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index dce39d03da7f..87868148e0e8 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -80,6 +80,7 @@ export function pluginInitializerContextConfigMock(config: T) { configIndex: '.opensearch_dashboards_config_tests', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), + dashboardAdmin: { groups: [], users: [] }, }, opensearch: { shardTimeout: duration('30s'), diff --git a/src/core/server/opensearch_dashboards_config.ts b/src/core/server/opensearch_dashboards_config.ts index 47fa8a126501..b823d4f83e2d 100644 --- a/src/core/server/opensearch_dashboards_config.ts +++ b/src/core/server/opensearch_dashboards_config.ts @@ -91,6 +91,14 @@ export const config = { defaultValue: 'https://survey.opensearch.org', }), }), + dashboardAdmin: schema.object({ + groups: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + users: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + }), }), deprecations, }; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 57aa372514de..ac793967d96b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -101,6 +101,7 @@ describe('createPluginInitializerContext', () => { configIndex: '.opensearch_dashboards_config', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), + dashboardAdmin: { groups: [], users: [] }, }, opensearch: { shardTimeout: duration(30, 's'), diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index c225a24aa386..e9c7591f6c56 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -292,6 +292,7 @@ export const SharedGlobalConfigKeys = { 'configIndex', 'autocompleteTerminateAfter', 'autocompleteTimeout', + 'dashboardAdmin', ] as const, opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] as const, path: ['data'] as const, diff --git a/src/core/server/utils/workspace.test.ts b/src/core/server/utils/workspace.test.ts index 7dfcff9e5d18..19f8bad4f866 100644 --- a/src/core/server/utils/workspace.test.ts +++ b/src/core/server/utils/workspace.test.ts @@ -11,9 +11,11 @@ describe('updateWorkspaceState', () => { const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); updateWorkspaceState(requestMock, { requestWorkspaceId: 'foo', + isDashboardAdmin: true, }); expect(getWorkspaceState(requestMock)).toEqual({ requestWorkspaceId: 'foo', + isDashboardAdmin: true, }); }); }); diff --git a/src/core/server/utils/workspace.ts b/src/core/server/utils/workspace.ts index 2003e615d501..89f2b7975964 100644 --- a/src/core/server/utils/workspace.ts +++ b/src/core/server/utils/workspace.ts @@ -7,6 +7,7 @@ import { OpenSearchDashboardsRequest, ensureRawRequest } from '../http/router'; export interface WorkspaceState { requestWorkspaceId?: string; + isDashboardAdmin?: boolean; } /** @@ -29,8 +30,9 @@ export const updateWorkspaceState = ( }; export const getWorkspaceState = (request: OpenSearchDashboardsRequest): WorkspaceState => { - const { requestWorkspaceId } = ensureRawRequest(request).app as WorkspaceState; + const { requestWorkspaceId, isDashboardAdmin } = ensureRawRequest(request).app as WorkspaceState; return { requestWorkspaceId, + isDashboardAdmin, }; }; diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a102268effca..84d457f06ca4 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -251,6 +251,10 @@ export default () => survey: Joi.object({ url: Joi.any().default('/'), }), + dashboardAdmin: Joi.object({ + groups: Joi.array().items(Joi.string()).default([]), + users: Joi.array().items(Joi.string()).default([]), + }), }).default(), savedObjects: HANDLED_IN_NEW_PLATFORM, diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 7d94a7491a00..039486a22cbf 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -6,6 +6,6 @@ "requiredPlugins": [ "savedObjects" ], - "optionalPlugins": ["savedObjectsManagement"], + "optionalPlugins": ["savedObjectsManagement", "applicationConfig"], "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 0ad72b51b6dc..f3f13fa27513 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -6,9 +6,17 @@ import { OnPreRoutingHandler } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; +import { AppPluginSetupDependencies } from './types'; import { getWorkspaceState } from '../../../core/server/utils'; describe('Workspace server plugin', () => { + const mockApplicationConfig = { + getConfigurationClient: jest.fn().mockResolvedValue({}), + registerConfigurationClient: jest.fn().mockResolvedValue({}), + }; + const mockDependencies: AppPluginSetupDependencies = { + applicationConfig: mockApplicationConfig, + }; it('#setup', async () => { let value; const setupMock = coreMock.createSetup(); @@ -17,7 +25,7 @@ describe('Workspace server plugin', () => { }); setupMock.capabilities.registerProvider.mockImplementationOnce((fn) => (value = fn())); const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, mockDependencies); expect(value).toMatchInlineSnapshot(` Object { "workspaces": Object { @@ -43,7 +51,7 @@ describe('Workspace server plugin', () => { return fn; }); const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, mockDependencies); const toolKitMock = httpServerMock.createToolkit(); const requestWithWorkspaceInUrl = httpServerMock.createOpenSearchDashboardsRequest({ @@ -78,7 +86,7 @@ describe('Workspace server plugin', () => { }); const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, mockDependencies); await workspacePlugin.start(startMock); expect(startMock.savedObjects.createSerializer).toBeCalledTimes(1); }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 6c9ff5a0424a..b4601d5d71dc 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -18,7 +18,12 @@ import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_ID_CONSUMER_WRAPPER_ID, } from '../common/constants'; -import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; +import { + IWorkspaceClientImpl, + WorkspacePluginSetup, + WorkspacePluginStart, + AppPluginSetupDependencies, +} from './types'; import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; @@ -32,6 +37,11 @@ import { SavedObjectsPermissionControl, SavedObjectsPermissionControlContract, } from './permission_control/client'; +import { + getApplicationOSDAdminConfig, + getOSDAdminConfig, + updateDashboardAdminStateForRequest, +} from './utils'; import { WorkspaceIdConsumerWrapper } from './saved_objects/workspace_id_consumer_wrapper'; export class WorkspacePlugin implements Plugin { @@ -64,12 +74,51 @@ export class WorkspacePlugin implements Plugin { + let groups: string[]; + let users: string[]; + let configGroups: string[]; + let configUsers: string[]; + + // There may be calls to saved objects client before user get authenticated, need to add a try catch here as `getPrincipalsFromRequest` will throw error when user is not authenticated. + try { + ({ groups = [], users = [] } = this.permissionControl!.getPrincipalsFromRequest(request)); + } catch (e) { + return toolkit.next(); + } + + if (!!applicationConfig) { + [configGroups, configUsers] = await getApplicationOSDAdminConfig( + { applicationConfig }, + request + ); + } else { + [configGroups, configUsers] = await getOSDAdminConfig(this.globalConfig$); + } + updateDashboardAdminStateForRequest(request, groups, users, configGroups, configUsers); + return toolkit.next(); + }); + + this.workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( + this.permissionControl + ); + + core.savedObjects.addClientWrapper( + 0, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + this.workspaceSavedObjectsClientWrapper.wrapperFactory + ); + } + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); this.globalConfig$ = initializerContext.config.legacy.globalConfig$; } - public async setup(core: CoreSetup) { + public async setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) { this.logger.debug('Setting up Workspaces service'); const globalConfig = await this.globalConfig$.pipe(first()).toPromise(); const isPermissionControlEnabled = globalConfig.savedObjects.permission.enabled === true; @@ -95,19 +144,7 @@ export class WorkspacePlugin implements Plugin { const savedObjects: Array<{ type: string; id: string }> = []; @@ -51,6 +52,7 @@ const repositoryKit = (() => { const permittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); const notPermittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); +const dashboardAdminRequest = httpServerMock.createOpenSearchDashboardsRequest(); describe('WorkspaceSavedObjectsClientWrapper', () => { let internalSavedObjectsRepository: ISavedObjectsRepository; @@ -59,6 +61,7 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { let osd: TestOpenSearchDashboardsUtils; let permittedSavedObjectedClient: SavedObjectsClientContract; let notPermittedSavedObjectedClient: SavedObjectsClientContract; + let dashboardAdminSavedObjectedClient: SavedObjectsClientContract; beforeAll(async function () { servers = createTestServers({ @@ -133,6 +136,10 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { notPermittedSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient( notPermittedRequest ); + updateWorkspaceState(dashboardAdminRequest, { isDashboardAdmin: true }); + dashboardAdminSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient( + dashboardAdminRequest + ); }); afterAll(async () => { @@ -172,6 +179,17 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { (await permittedSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')).error ).toBeUndefined(); }); + + it('should return consistent dashboard when groups/users is dashboard admin', async () => { + expect( + (await dashboardAdminSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1')) + .error + ).toBeUndefined(); + expect( + (await dashboardAdminSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')) + .error + ).toBeUndefined(); + }); }); describe('bulkGet', () => { @@ -215,6 +233,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { ).saved_objects.length ).toEqual(1); }); + + it('should return consistent dashboard when groups/users is dashboard admin', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1' }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await dashboardAdminSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'acl-controlled-dashboard-2' }, + ]) + ).saved_objects.length + ).toEqual(1); + }); }); describe('find', () => { @@ -246,6 +281,19 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { true ); }); + + it('should return consistent inner workspace data when groups/users is dashboard admin', async () => { + const result = await dashboardAdminSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }); + + expect(result.saved_objects.some((item) => item.id === 'inner-workspace-dashboard-1')).toBe( + true + ); + }); }); describe('create', () => { @@ -278,6 +326,18 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await permittedSavedObjectedClient.delete('dashboard', createResult.id); }); + it('should able to create saved objects into any workspaces after create called when groups/users is dashboard admin', async () => { + const createResult = await dashboardAdminSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + expect(createResult.error).toBeUndefined(); + await dashboardAdminSavedObjectedClient.delete('dashboard', createResult.id); + }); + it('should throw forbidden error when create with override', async () => { let error; try { @@ -309,6 +369,20 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(createResult.error).toBeUndefined(); }); + + it('should able to create with override when groups/users is dashboard admin', async () => { + const createResult = await dashboardAdminSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.error).toBeUndefined(); + }); }); describe('bulkCreate', () => { @@ -337,6 +411,18 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await permittedSavedObjectedClient.delete('dashboard', objectId); }); + it('should able to create saved objects into any workspaces after bulkCreate called when groups/users is dashboard damin', async () => { + const objectId = new Date().getTime().toString(16).toUpperCase(); + const result = await dashboardAdminSavedObjectedClient.bulkCreate( + [{ type: 'dashboard', attributes: {}, id: objectId }], + { + workspaces: ['workspace-1'], + } + ); + expect(result.saved_objects.length).toEqual(1); + await dashboardAdminSavedObjectedClient.delete('dashboard', objectId); + }); + it('should throw forbidden error when create with override', async () => { let error; try { @@ -377,6 +463,24 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(createResult.saved_objects).toHaveLength(1); }); + + it('should able to bulk create with override when groups/users is dashboard admin', async () => { + const createResult = await dashboardAdminSavedObjectedClient.bulkCreate( + [ + { + id: 'inner-workspace-dashboard-1', + type: 'dashboard', + attributes: {}, + }, + ], + { + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.saved_objects).toHaveLength(1); + }); }); describe('update', () => { @@ -414,6 +518,27 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { .error ).toBeUndefined(); }); + + it('should update saved objects for any workspaces when groups/users is dashboard admin', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.update( + 'dashboard', + 'inner-workspace-dashboard-1', + {} + ) + ).error + ).toBeUndefined(); + expect( + ( + await dashboardAdminSavedObjectedClient.update( + 'dashboard', + 'acl-controlled-dashboard-2', + {} + ) + ).error + ).toBeUndefined(); + }); }); describe('bulkUpdate', () => { @@ -459,6 +584,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { ).saved_objects.length ).toEqual(1); }); + + it('should bulk update saved objects for any workspaces when groups/users is dashboard admin', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await dashboardAdminSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + }); }); describe('delete', () => { @@ -526,6 +668,52 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { } expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); }); + + it('should be able to delete any data when groups/users is dashboard admin', async () => { + const createPermittedResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + permissions: { + read: { users: ['foo'] }, + write: { users: ['foo'] }, + }, + } + ); + + await dashboardAdminSavedObjectedClient.delete('dashboard', createPermittedResult.id); + + let permittedError; + try { + permittedError = await dashboardAdminSavedObjectedClient.get( + 'dashboard', + createPermittedResult.id + ); + } catch (e) { + permittedError = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(permittedError)).toBe(true); + + const createACLResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + + await dashboardAdminSavedObjectedClient.delete('dashboard', createACLResult.id); + + let ACLError; + try { + ACLError = await dashboardAdminSavedObjectedClient.get('dashboard', createACLResult.id); + } catch (e) { + ACLError = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(ACLError)).toBe(true); + }); }); describe('deleteByWorkspace', () => { diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts index 07d1e6aff40c..c000d72a2f7a 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -3,10 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { getWorkspaceState, updateWorkspaceState } from '../../../../core/server/utils'; import { SavedObjectsErrorHelpers } from '../../../../core/server'; import { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; +import { httpServerMock } from '../../../../core/server/mocks'; -const generateWorkspaceSavedObjectsClientWrapper = () => { +const DASHBOARD_ADMIN = 'dashnoard_admin'; +const NO_DASHBOARD_ADMIN = 'no_dashnoard_admin'; + +const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) => { const savedObjectsStore = [ { type: 'dashboard', @@ -75,7 +80,8 @@ const generateWorkspaceSavedObjectsClientWrapper = () => { find: jest.fn(), deleteByWorkspace: jest.fn(), }; - const requestMock = {}; + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + if (role === DASHBOARD_ADMIN) updateWorkspaceState(requestMock, { isDashboardAdmin: true }); const wrapperOptions = { client: clientMock, request: requestMock, @@ -91,8 +97,11 @@ const generateWorkspaceSavedObjectsClientWrapper = () => { }), validateSavedObjectsACL: jest.fn(), batchValidate: jest.fn(), - getPrincipalsFromRequest: jest.fn().mockImplementation(() => ({ users: ['user-1'] })), + getPrincipalsFromRequest: jest.fn().mockImplementation(() => { + return { users: ['user-1'] }; + }), }; + const wrapper = new WorkspaceSavedObjectsClientWrapper(permissionControlMock); const scopedClientMock = { find: jest.fn().mockImplementation(async () => ({ @@ -152,6 +161,21 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.delete(...deleteArgs); expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); }); + it('should call client.delete if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + expect(getWorkspaceState(requestMock)).toEqual({ + isDashboardAdmin: true, + }); + const deleteArgs = ['dashboard', 'not-permitted-dashboard'] as const; + await wrapper.delete(...deleteArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); + }); }); describe('update', () => { @@ -206,6 +230,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.update(...updateArgs); expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); }); + it('should call client.update if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const updateArgs = [ + 'dashboard', + 'not-permitted-dashboard', + { + bar: 'for', + }, + ] as const; + await wrapper.update(...updateArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); + }); }); describe('bulk update', () => { @@ -241,6 +282,19 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.bulkUpdate(objectsToUpdate, {}); expect(clientMock.bulkUpdate).toHaveBeenCalledWith(objectsToUpdate, {}); }); + it('should call client.bulkUpdate if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const bulkUpdateArgs = [ + { type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }, + ]; + await wrapper.bulkUpdate(bulkUpdateArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.bulkUpdate).toHaveBeenCalledWith(bulkUpdateArgs); + }); }); describe('bulk create', () => { @@ -343,6 +397,25 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { workspaces: ['workspace-1'], }); }); + it('should call client.bulkCreate if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const objectsToBulkCreate = [ + { type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }, + ]; + await wrapper.bulkCreate(objectsToBulkCreate, { + overwrite: true, + workspaces: ['not-permitted-workspace'], + }); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + workspaces: ['not-permitted-workspace'], + }); + }); }); describe('create', () => { @@ -417,6 +490,30 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { } ); }); + it('should call client.create if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + await wrapper.create( + 'dashboard', + { foo: 'bar' }, + { + id: 'not-permitted-dashboard', + overwrite: true, + } + ); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.create).toHaveBeenCalledWith( + 'dashboard', + { foo: 'bar' }, + { + id: 'not-permitted-dashboard', + overwrite: true, + } + ); + }); }); describe('get', () => { it('should return saved object if no need to validate permission', async () => { @@ -478,6 +575,18 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(clientMock.get).toHaveBeenCalledWith(...getArgs); expect(result).toMatchInlineSnapshot(`[Error: Not Found]`); }); + it('should call client.get and return result with arguments if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const getArgs = ['dashboard', 'not-permitted-dashboard'] as const; + const result = await wrapper.get(...getArgs); + expect(clientMock.get).toHaveBeenCalledWith(...getArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(result.id).toBe('not-permitted-dashboard'); + }); }); describe('bulk get', () => { it("should call permission validate with object's workspace and throw permission error", async () => { @@ -543,6 +652,27 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { {} ); }); + it('should call client.bulkGet and return result with arguments if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const bulkGetArgs = [ + { + type: 'dashboard', + id: 'foo', + }, + { + type: 'dashboard', + id: 'not-permitted-dashboard', + }, + ]; + const result = await wrapper.bulkGet(bulkGetArgs); + expect(clientMock.bulkGet).toHaveBeenCalledWith(bulkGetArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(result.saved_objects.length).toBe(2); + }); }); describe('find', () => { it('should call client.find with ACLSearchParams for workspace type', async () => { @@ -634,6 +764,22 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { }, }); }); + it('should call client.find with arguments if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + await wrapper.find({ + type: 'dashboard', + workspaces: ['workspace-1', 'not-permitted-workspace'], + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'dashboard', + workspaces: ['workspace-1', 'not-permitted-workspace'], + }); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); }); describe('deleteByWorkspace', () => { it('should call permission validate with workspace and throw workspace permission error if not permitted', async () => { @@ -662,6 +808,16 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.deleteByWorkspace('workspace-1', {}); expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('workspace-1', {}); }); + it('should call client.deleteByWorkspace if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + await wrapper.deleteByWorkspace('not-permitted-workspace'); + expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('not-permitted-workspace'); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 4d5d03641b5f..3101b74598bb 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -5,6 +5,7 @@ import { i18n } from '@osd/i18n'; +import { getWorkspaceState } from '../../../../core/server/utils'; import { OpenSearchDashboardsRequest, SavedObject, @@ -519,6 +520,11 @@ export class WorkspaceSavedObjectsClientWrapper { return await wrapperOptions.client.deleteByWorkspace(workspace, options); }; + const isDashboardAdmin = getWorkspaceState(wrapperOptions.request)?.isDashboardAdmin; + if (isDashboardAdmin) { + return wrapperOptions.client; + } + return { ...wrapperOptions.client, get: getWithWorkspacePermissionControl, diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index b506bb493a4c..6a9009a06375 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ApplicationConfigPluginSetup } from 'src/plugins/application_config/server'; import { Logger, OpenSearchDashboardsRequest, @@ -127,6 +128,9 @@ export interface AuthInfo { user_name?: string; } +export interface AppPluginSetupDependencies { + applicationConfig: ApplicationConfigPluginSetup; +} export interface WorkspacePluginSetup { client: IWorkspaceClientImpl; } diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 1f6c3e58f122..639dfb3963e5 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -5,7 +5,17 @@ import { AuthStatus } from '../../../core/server'; import { httpServerMock, httpServiceMock } from '../../../core/server/mocks'; -import { generateRandomId, getPrincipalsFromRequest } from './utils'; +import { + generateRandomId, + getApplicationOSDAdminConfig, + getOSDAdminConfig, + getPrincipalsFromRequest, + stringToArray, + updateDashboardAdminStateForRequest, +} from './utils'; +import { getWorkspaceState } from '../../../core/server/utils'; +import { AppPluginSetupDependencies } from './types'; +import { Observable, of } from 'rxjs'; describe('workspace utils', () => { const mockAuth = httpServiceMock.createAuth(); @@ -73,4 +83,121 @@ describe('workspace utils', () => { 'UNEXPECTED_AUTHORIZATION_STATUS' ); }); + + it('should be dashboard admin when users match configUsers', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = ['dashboard_admin']; + const users: string[] = []; + const configGroups: string[] = ['dashboard_admin']; + const configUsers: string[] = []; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(true); + }); + + it('should be dashboard admin when groups match configGroups', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = []; + const users: string[] = ['dashboard_admin']; + const configGroups: string[] = []; + const configUsers: string[] = ['dashboard_admin']; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(true); + }); + + it('should be not dashboard admin when groups do not match configGroups', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = ['dashboard_admin']; + const users: string[] = []; + const configGroups: string[] = []; + const configUsers: string[] = ['dashboard_admin']; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(false); + }); + + it('should be not dashboard admin when groups and users are []', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = []; + const users: string[] = []; + const configGroups: string[] = []; + const configUsers: string[] = []; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(false); + }); + + it('should convert string to array', () => { + const jsonString = '["test1","test2"]'; + const strToArray = stringToArray(jsonString); + expect(strToArray).toStrictEqual(new Array('test1', 'test2')); + }); + + it('should convert string to a null array if input is invalid', () => { + const jsonString = '["test1", test2]'; + const strToArray = stringToArray(jsonString); + expect(strToArray).toStrictEqual([]); + }); + + it('should get correct OSD admin config when application config is enabled', async () => { + const applicationConfigMock = { + getConfigurationClient: jest.fn().mockReturnValue({ + getEntityConfig: jest.fn().mockImplementation(async (entity: string) => { + if (entity === 'opensearchDashboards.dashboardAdmin.groups') { + return '["group1", "group2"]'; + } else if (entity === 'opensearchDashboards.dashboardAdmin.users') { + return '["user1", "user2"]'; + } else { + return undefined; + } + }), + }), + registerConfigurationClient: jest.fn().mockResolvedValue({}), + }; + + const mockDependencies: AppPluginSetupDependencies = { + applicationConfig: applicationConfigMock, + }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const [groups, users] = await getApplicationOSDAdminConfig(mockDependencies, mockRequest); + expect(groups).toEqual(['group1', 'group2']); + expect(users).toEqual(['user1', 'user2']); + }); + + it('should get [] when application config is enabled and not defined ', async () => { + const applicationConfigMock = { + getConfigurationClient: jest.fn().mockReturnValue({ + getEntityConfig: jest.fn().mockImplementation(async (entity: string) => { + throw new Error('Not found'); + }), + }), + registerConfigurationClient: jest.fn().mockResolvedValue({}), + }; + + const mockDependencies: AppPluginSetupDependencies = { + applicationConfig: applicationConfigMock, + }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const [groups, users] = await getApplicationOSDAdminConfig(mockDependencies, mockRequest); + expect(groups).toEqual([]); + expect(users).toEqual([]); + }); + + it('should get correct admin config when admin config is enabled ', async () => { + const globalConfig$: Observable = of({ + opensearchDashboards: { + dashboardAdmin: { + groups: ['group1', 'group2'], + users: ['user1', 'user2'], + }, + }, + }); + const [groups, users] = await getOSDAdminConfig(globalConfig$); + expect(groups).toEqual(['group1', 'group2']); + expect(users).toEqual(['user1', 'user2']); + }); + + it('should get [] when admin config is not enabled', async () => { + const globalConfig$: Observable = of({}); + const [groups, users] = await getOSDAdminConfig(globalConfig$); + expect(groups).toEqual([]); + expect(users).toEqual([]); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 1c8d73953afa..79fcc60aad5d 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -4,14 +4,18 @@ */ import crypto from 'crypto'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { AuthStatus, HttpAuth, OpenSearchDashboardsRequest, Principals, PrincipalType, + SharedGlobalConfig, } from '../../../core/server'; -import { AuthInfo } from './types'; +import { AppPluginSetupDependencies, AuthInfo } from './types'; +import { updateWorkspaceState } from '../../../core/server/utils'; /** * Generate URL friendly random ID @@ -50,3 +54,63 @@ export const getPrincipalsFromRequest = ( throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); }; + +export const updateDashboardAdminStateForRequest = ( + request: OpenSearchDashboardsRequest, + groups: string[], + users: string[], + configGroups: string[], + configUsers: string[] +) => { + if (configGroups.length === 0 && configUsers.length === 0) { + updateWorkspaceState(request, { + isDashboardAdmin: false, + }); + return; + } + const groupMatchAny = groups.some((group) => configGroups.includes(group)) || false; + const userMatchAny = users.some((user) => configUsers.includes(user)) || false; + updateWorkspaceState(request, { + isDashboardAdmin: groupMatchAny || userMatchAny, + }); +}; + +export const stringToArray = (adminConfig: string | undefined) => { + if (!adminConfig) { + return []; + } + let adminConfigArray; + try { + adminConfigArray = JSON.parse(adminConfig); + } catch (e) { + return []; + } + return adminConfigArray; +}; + +export const getApplicationOSDAdminConfig = async ( + { applicationConfig }: AppPluginSetupDependencies, + request: OpenSearchDashboardsRequest +) => { + const applicationConfigClient = applicationConfig.getConfigurationClient(request); + + const [groupsResult, usersResult] = await Promise.all([ + applicationConfigClient + .getEntityConfig('opensearchDashboards.dashboardAdmin.groups') + .catch(() => undefined), + applicationConfigClient + .getEntityConfig('opensearchDashboards.dashboardAdmin.users') + .catch(() => undefined), + ]); + + return [stringToArray(groupsResult), stringToArray(usersResult)]; +}; + +export const getOSDAdminConfig = async (globalConfig$: Observable) => { + const globalConfig = await globalConfig$.pipe(first()).toPromise(); + const groupsResult = (globalConfig.opensearchDashboards?.dashboardAdmin?.groups || + []) as string[]; + const usersResult = (globalConfig.opensearchDashboards?.dashboardAdmin?.users || []) as string[]; + + return [groupsResult, usersResult]; +};