From 5c05e0e91832b0e09132df0a8d14803def200090 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 May 2024 09:08:19 +0000 Subject: [PATCH] [Workspace] Dashboard admin(groups/users) implementation (#6554) * [Workspace] dashboard admin(groups/users) implementation and integrating with dynamic application config Signed-off-by: yubonluo * Modify change log Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * modify change log Signed-off-by: yubonluo * modify change log Signed-off-by: yubonluo * solve change log issue Signed-off-by: yubonluo * Changeset file for PR #6554 created/updated * [Workspace] delete useless code Signed-off-by: yubonluo * Changeset file for PR #6554 created/updated * delete useless code Signed-off-by: yubonluo * Optimize the code Signed-off-by: yubonluo * Add unit test to cover setupPermission in plugin. Signed-off-by: yubonluo * delete the logic of dynamic application config Signed-off-by: yubonluo * Default to OSD admin if security uninstall Signed-off-by: yubonluo * Default to OSD admin if security uninstall Signed-off-by: yubonluo --------- Signed-off-by: yubonluo Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> (cherry picked from commit b839da1b63aed839b67b85b9cc2cdde14e5ea1b6) Signed-off-by: github-actions[bot] --- changelogs/fragments/6554.yml | 2 + config/opensearch_dashboards.yml | 5 + src/core/server/mocks.ts | 1 + .../server/opensearch_dashboards_config.ts | 8 + .../server/plugins/plugin_context.test.ts | 1 + src/core/server/plugins/types.ts | 1 + src/core/server/utils/workspace.test.ts | 2 + src/core/server/utils/workspace.ts | 4 +- src/legacy/server/config/schema.js | 4 + src/plugins/workspace/server/plugin.test.ts | 67 ++++++- src/plugins/workspace/server/plugin.ts | 45 +++-- ...space_saved_objects_client_wrapper.test.ts | 181 ++++++++++++++++++ ...space_saved_objects_client_wrapper.test.ts | 125 +++++++++++- .../workspace_saved_objects_client_wrapper.ts | 6 + src/plugins/workspace/server/utils.test.ts | 80 +++++++- src/plugins/workspace/server/utils.ts | 39 ++++ 16 files changed, 552 insertions(+), 19 deletions(-) create mode 100644 changelogs/fragments/6554.yml diff --git a/changelogs/fragments/6554.yml b/changelogs/fragments/6554.yml new file mode 100644 index 000000000000..b3944b967773 --- /dev/null +++ b/changelogs/fragments/6554.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Dashboard admin(groups/users) implementation. ([#6554](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6554)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 3e5359675f05..9208e8e39729 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -328,3 +328,8 @@ # Set the value to true to enable enhancements for the data plugin # data.enhancements.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 af9883000f34..d688afbd5450 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: [] }, futureNavigation: false, }, opensearch: { diff --git a/src/core/server/opensearch_dashboards_config.ts b/src/core/server/opensearch_dashboards_config.ts index 68973f3dd648..62a1128356d6 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: [], + }), + }), futureNavigation: schema.boolean({ defaultValue: false }), }), deprecations, diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 5616ec8b3b28..3bd8d711c011 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: [] }, futureNavigation: false, }, opensearch: { diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 4a0f095bd58f..9ce1682a371b 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', 'futureNavigation', ] as const, opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] 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 4c8d5c2bce6c..78a33d16876b 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -252,6 +252,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([]), + }), futureNavigation: Joi.boolean().default(false), }).default(), diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index e065066cd671..0b943cf47252 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -3,10 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { OnPreRoutingHandler } from 'src/core/server'; +import { OnPostAuthHandler, OnPreRoutingHandler } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; import { getWorkspaceState } from '../../../core/server/utils'; +import * as utilsExports from './utils'; describe('Workspace server plugin', () => { it('#setup', async () => { @@ -67,6 +68,70 @@ describe('Workspace server plugin', () => { expect(toolKitMock.next).toBeCalledTimes(1); }); + describe('#setupPermission', () => { + const setupMock = coreMock.createSetup(); + const initializerContextConfigMock = coreMock.createPluginInitializerContext({ + enabled: true, + permission: { + enabled: true, + }, + }); + let registerOnPostAuthFn: OnPostAuthHandler = () => httpServerMock.createResponseFactory().ok(); + setupMock.http.registerOnPostAuth.mockImplementation((fn) => { + registerOnPostAuthFn = fn; + return fn; + }); + const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); + const requestWithWorkspaceInUrl = httpServerMock.createOpenSearchDashboardsRequest({ + path: '/w/foo/app', + }); + + it('catch error', async () => { + await workspacePlugin.setup(setupMock); + const toolKitMock = httpServerMock.createToolkit(); + + await registerOnPostAuthFn( + requestWithWorkspaceInUrl, + httpServerMock.createResponseFactory(), + toolKitMock + ); + expect(toolKitMock.next).toBeCalledTimes(1); + }); + + it('with yml config', async () => { + jest + .spyOn(utilsExports, 'getPrincipalsFromRequest') + .mockImplementation(() => ({ users: [`user1`] })); + jest + .spyOn(utilsExports, 'getOSDAdminConfigFromYMLConfig') + .mockResolvedValue([['group1'], ['user1']]); + + await workspacePlugin.setup(setupMock); + const toolKitMock = httpServerMock.createToolkit(); + + await registerOnPostAuthFn( + requestWithWorkspaceInUrl, + httpServerMock.createResponseFactory(), + toolKitMock + ); + expect(toolKitMock.next).toBeCalledTimes(1); + }); + + it('uninstall security plugin', async () => { + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({})); + + await workspacePlugin.setup(setupMock); + const toolKitMock = httpServerMock.createToolkit(); + + await registerOnPostAuthFn( + requestWithWorkspaceInUrl, + httpServerMock.createResponseFactory(), + toolKitMock + ); + expect(toolKitMock.next).toBeCalledTimes(1); + }); + }); + it('#start', async () => { const setupMock = coreMock.createSetup(); const startMock = coreMock.createStart(); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index b53a00b39afa..2ec8447ad817 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -37,6 +37,7 @@ import { SavedObjectsPermissionControl, SavedObjectsPermissionControlContract, } from './permission_control/client'; +import { getOSDAdminConfigFromYMLConfig, updateDashboardAdminStateForRequest } from './utils'; import { WorkspaceIdConsumerWrapper } from './saved_objects/workspace_id_consumer_wrapper'; import { WorkspaceUiSettingsClientWrapper } from './saved_objects/workspace_ui_settings_client_wrapper'; @@ -71,6 +72,36 @@ export class WorkspacePlugin implements Plugin { + let groups: string[]; + let users: 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(); + } + + const [configGroups, configUsers] = await getOSDAdminConfigFromYMLConfig(this.globalConfig$); + updateDashboardAdminStateForRequest(request, groups, users, configGroups, configUsers); + return toolkit.next(); + }); + + this.workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( + this.permissionControl + ); + + core.savedObjects.addClientWrapper( + PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + this.workspaceSavedObjectsClientWrapper.wrapperFactory + ); + } + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.globalConfig$ = initializerContext.config.legacy.globalConfig$; @@ -110,19 +141,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 () => { @@ -593,4 +600,178 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { ).toBe(0); }); }); + + describe('Dashboard admin', () => { + it('should return consistent dashboard after get called', async () => { + expect( + (await dashboardAdminSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1')) + .error + ).toBeUndefined(); + expect( + (await dashboardAdminSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')) + .error + ).toBeUndefined(); + }); + it('should return consistent dashboard after bulkGet called', 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); + }); + it('should return consistent inner workspace data after find called', 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 + ); + }); + it('should able to create saved objects into any workspaces after create called', async () => { + const createResult = await dashboardAdminSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + expect(createResult.error).toBeUndefined(); + await dashboardAdminSavedObjectedClient.delete('dashboard', createResult.id); + }); + it('should able to create with override after create called', async () => { + const createResult = await dashboardAdminSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.error).toBeUndefined(); + }); + it('should able to bulk create with override after bulkCreate called', 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); + }); + it('should able to create saved objects into any workspaces after bulkCreate called', 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 update saved objects for any workspaces after update called', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.update( + 'dashboard', + 'inner-workspace-dashboard-1', + {} + ) + ).error + ).toBeUndefined(); + expect( + ( + await dashboardAdminSavedObjectedClient.update( + 'dashboard', + 'acl-controlled-dashboard-2', + {} + ) + ).error + ).toBeUndefined(); + }); + it('should bulk update saved objects for any workspaces after bulkUpdate called', 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); + }); + it('should be able to delete any data after delete called', 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); + }); + }); }); 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 2706aa9275db..f488d83e7946 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 () => ({ @@ -713,5 +722,115 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('workspace-1', {}); }); }); + + describe('Dashboard admin', () => { + const { + wrapper, + clientMock, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + expect(getWorkspaceState(requestMock)).toEqual({ + isDashboardAdmin: true, + }); + it('should bypass permission check for call client.delete', async () => { + const deleteArgs = ['dashboard', 'not-permitted-dashboard'] as const; + await wrapper.delete(...deleteArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); + }); + it('should bypass permission check for call client.update', async () => { + const updateArgs = [ + 'dashboard', + 'not-permitted-dashboard', + { + bar: 'for', + }, + ] as const; + await wrapper.update(...updateArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); + }); + it('should bypass permission check for call client.bulkUpdate', async () => { + 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); + }); + it('should bypass permission check for call client.bulkCreate', async () => { + 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'], + }); + }); + it('should bypass permission check for call client.create', async () => { + 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, + } + ); + }); + it('should bypass permission check for call client.get and return result with arguments', async () => { + 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'); + }); + it('should bypass permission check for call client.bulkGet and return result with arguments', async () => { + 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); + }); + it('should bypass permission check for call client.find with arguments', async () => { + 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(); + }); + it('should bypass permission check for call client.deleteByWorkspace', async () => { + 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 26910b67b35f..f97d423b3058 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/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 1f6c3e58f122..15e6783c2cab 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -5,7 +5,14 @@ import { AuthStatus } from '../../../core/server'; import { httpServerMock, httpServiceMock } from '../../../core/server/mocks'; -import { generateRandomId, getPrincipalsFromRequest } from './utils'; +import { + generateRandomId, + getOSDAdminConfigFromYMLConfig, + getPrincipalsFromRequest, + updateDashboardAdminStateForRequest, +} from './utils'; +import { getWorkspaceState } from '../../../core/server/utils'; +import { Observable, of } from 'rxjs'; describe('workspace utils', () => { const mockAuth = httpServiceMock.createAuth(); @@ -73,4 +80,75 @@ 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 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(true); + }); + + it('should be dashboard admin when configGroups and configUsers are []', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = ['user1']; + const users: string[] = []; + const configGroups: string[] = []; + const configUsers: string[] = []; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(false); + }); + + 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 getOSDAdminConfigFromYMLConfig(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 getOSDAdminConfigFromYMLConfig(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..9037038f16af 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 { updateWorkspaceState } from '../../../core/server/utils'; /** * Generate URL friendly random ID @@ -50,3 +54,38 @@ export const getPrincipalsFromRequest = ( throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); }; + +export const updateDashboardAdminStateForRequest = ( + request: OpenSearchDashboardsRequest, + groups: string[], + users: string[], + configGroups: string[], + configUsers: string[] +) => { + // If the security plugin is not installed, login defaults to OSD Admin + if (!groups.length && !users.length) { + updateWorkspaceState(request, { isDashboardAdmin: true }); + return; + } + + if (!configGroups.length && !configUsers.length) { + updateWorkspaceState(request, { isDashboardAdmin: false }); + return; + } + const groupMatchAny = groups.some((group) => configGroups.includes(group)); + const userMatchAny = users.some((user) => configUsers.includes(user)); + updateWorkspaceState(request, { + isDashboardAdmin: groupMatchAny || userMatchAny, + }); +}; + +export const getOSDAdminConfigFromYMLConfig = 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]; +};