From b839da1b63aed839b67b85b9cc2cdde14e5ea1b6 Mon Sep 17 00:00:00 2001 From: yuboluo Date: Fri, 24 May 2024 17:06:42 +0800 Subject: [PATCH 1/3] [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> --- 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 8caf4ebf6af9..4688995182f3 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -334,3 +334,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]; +}; From 347639f4ad9e3e856a21bae48b161bfc64434644 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 24 May 2024 13:02:22 -0700 Subject: [PATCH 2/3] fix for quickrange to use datemath to parse datetime strings (#6782) provides a formatting util function meant to convert quick range time (such as 'now-15m') to datetimes that can be understood. Signed-off-by: Paul Sebastian --------- Signed-off-by: Paul Sebastian Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6782.yml | 2 ++ .../data_frames/data_frame_utils.test.ts | 31 +++++++++++++++++++ src/plugins/data/common/data_frames/utils.ts | 23 +++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/6782.yml create mode 100644 src/plugins/data/common/data_frames/data_frame_utils.test.ts diff --git a/changelogs/fragments/6782.yml b/changelogs/fragments/6782.yml new file mode 100644 index 000000000000..19349a8de101 --- /dev/null +++ b/changelogs/fragments/6782.yml @@ -0,0 +1,2 @@ +fix: +- Quickrange selection fix ([#6782](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6782)) \ No newline at end of file diff --git a/src/plugins/data/common/data_frames/data_frame_utils.test.ts b/src/plugins/data/common/data_frames/data_frame_utils.test.ts new file mode 100644 index 000000000000..bb812a290941 --- /dev/null +++ b/src/plugins/data/common/data_frames/data_frame_utils.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TimeRange } from '../types'; +import { formatTimePickerDate } from './utils'; + +describe('Data Frame Utils', () => { + describe('formatTimePickerDate function', () => { + Date.now = jest.fn(() => new Date('2024-05-04T12:30:00.000Z')); + + test('should return a correctly formatted date', () => { + const range = { from: 'now-15m', to: 'now' } as TimeRange; + const formattedDate = formatTimePickerDate(range, 'YYYY-MM-DD HH:mm:ss.SSS'); + expect(formattedDate).toStrictEqual({ + fromDate: '2024-05-04 12:15:00.000', + toDate: '2024-05-04 12:30:00.000', + }); + }); + + test('should indicate invalid when given bad dates', () => { + const range = { from: 'fake', to: 'date' } as TimeRange; + const formattedDate = formatTimePickerDate(range, 'YYYY-MM-DD HH:mm:ss.SSS'); + expect(formattedDate).toStrictEqual({ + fromDate: 'Invalid date', + toDate: 'Invalid date', + }); + }); + }); +}); diff --git a/src/plugins/data/common/data_frames/utils.ts b/src/plugins/data/common/data_frames/utils.ts index c3c55c5f227c..6569136b8fa3 100644 --- a/src/plugins/data/common/data_frames/utils.ts +++ b/src/plugins/data/common/data_frames/utils.ts @@ -17,7 +17,7 @@ import { import { IFieldType } from './fields'; import { IndexPatternFieldMap, IndexPatternSpec } from '../index_patterns'; import { IOpenSearchDashboardsSearchRequest } from '../search'; -import { GetAggTypeFn, GetDataFrameAggQsFn } from '../types'; +import { GetAggTypeFn, GetDataFrameAggQsFn, TimeRange } from '../types'; /** * Returns the raw data frame from the search request. @@ -290,6 +290,27 @@ export const getTimeField = ( : fields.find((field) => field.type === 'date'); }; +/** + * Parses timepicker datetimes using datemath package. Will attempt to parse strings such as + * "now - 15m" + * + * @param dateRange - of type TimeRange + * @param dateFormat - formatting string (should work with Moment) + * @returns object with `fromDate` and `toDate` strings, both of which will be in utc time and formatted to + * the `dateFormat` parameter + */ +export const formatTimePickerDate = (dateRange: TimeRange, dateFormat: string) => { + const dateMathParse = (date: string) => { + const parsedDate = datemath.parse(date); + return parsedDate ? parsedDate.utc().format(dateFormat) : ''; + }; + + const fromDate = dateMathParse(dateRange.from); + const toDate = dateMathParse(dateRange.to); + + return { fromDate, toDate }; +}; + /** * Checks if the value is a GeoPoint. Expects an object with lat and lon properties. * From 8d50974a66e7accee6469c3a7a86812979ca5471 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Mon, 27 May 2024 08:47:44 +0800 Subject: [PATCH 3/3] fix vega visualization error message not been formatted (#6777) * fix vega visualization error message not been formatted --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6777.yml | 2 ++ src/plugins/vis_type_vega/public/data_model/vega_parser.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/6777.yml diff --git a/changelogs/fragments/6777.yml b/changelogs/fragments/6777.yml new file mode 100644 index 000000000000..9e3a63925baa --- /dev/null +++ b/changelogs/fragments/6777.yml @@ -0,0 +1,2 @@ +fix: +- Error message is not formatted in vis_type_vega url parser. ([#6777](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6777)) \ No newline at end of file diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index 0f0f9b32bb4b..927fbec6615b 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -674,7 +674,7 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never i18n.translate('visTypeVega.vegaParser.notSupportedUrlTypeErrorMessage', { defaultMessage: '{urlObject} is not supported', values: { - urlObject: 'url: {"%type%": "${type}"}', + urlObject: `url: {"%type%": "${type}"}`, }, }) );