diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 44e8be470c3..dbcd0053a5b 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -45,7 +45,11 @@ import { HttpFetchOptions, HttpSetup } from '../http'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' + | 'sortOrder' + | 'rootSearchFields' + | 'typeToNamespacesMap' + | 'ACLSearchParams' + | 'workspacesSearchOperator' >; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a1e44c959ec..ced49743c98 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -322,6 +322,10 @@ export { exportSavedObjectsToStream, importSavedObjectsFromStream, resolveSavedObjectsImportErrors, + ACL, + Principals, + PrincipalType, + Permissions, updateDataSourceNameInVegaSpec, extractVegaSpecFromSavedObject, } from './saved_objects'; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 687d408e40a..dce39d03da7 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -89,6 +89,9 @@ export function pluginInitializerContextConfigMock(config: T) { path: { data: '/tmp' }, savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400), + permission: { + enabled: true, + }, }, }; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 7a8ba042825..57aa372514d 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -108,7 +108,12 @@ describe('createPluginInitializerContext', () => { pingTimeout: duration(30, 's'), }, path: { data: fromRoot('data') }, - savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + savedObjects: { + maxImportPayloadBytes: new ByteSizeValue(26214400), + permission: { + enabled: false, + }, + }, }); }); diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 59b9881279c..c225a24aa38 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -295,7 +295,7 @@ export const SharedGlobalConfigKeys = { ] as const, opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] as const, path: ['data'] as const, - savedObjects: ['maxImportPayloadBytes'] as const, + savedObjects: ['maxImportPayloadBytes', 'permission'] as const, }; /** diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 06b2b65fd18..dccf63d4dcf 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -84,3 +84,5 @@ export { export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; + +export { Permissions, ACL, Principals, PrincipalType } from './permission_control/acl'; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 96883e55320..997771270ee 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -168,7 +168,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId, permissions }, + { type, id, references, namespace: objectNamespace, originId, permissions, workspaces }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -184,6 +184,7 @@ describe('SavedObjectsRepository', () => { ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), ...(originId && { originId }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), type, [type]: { title: 'Testing' }, references, @@ -3156,7 +3157,7 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const originId = 'some-origin-id'; - const getSuccess = async (type, id, options, includeOriginId, permissions) => { + const getSuccess = async (type, id, options, includeOriginId, permissions, workspaces) => { const response = getMockGetResponse( { type, @@ -3165,6 +3166,7 @@ describe('SavedObjectsRepository', () => { // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. ...(includeOriginId && { originId }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), }, options?.namespace ); @@ -3330,6 +3332,14 @@ describe('SavedObjectsRepository', () => { permissions: permissions, }); }); + + it(`includes workspaces property if present`, async () => { + const workspaces = ['workspace-1']; + const result = await getSuccess(type, id, { namespace }, undefined, undefined, workspaces); + expect(result).toMatchObject({ + workspaces: workspaces, + }); + }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 15dcf7a6c12..1f00976653c 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -799,6 +799,8 @@ export class SavedObjectsRepository { filter, preference, workspaces, + workspacesSearchOperator, + ACLSearchParams, } = options; if (!type && !typeToNamespacesMap) { @@ -873,6 +875,8 @@ export class SavedObjectsRepository { hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, }), }, }; @@ -1040,7 +1044,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt, permissions } = body._source; + const { originId, updated_at: updatedAt, permissions, workspaces } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1056,6 +1060,7 @@ export class SavedObjectsRepository { ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index a47bc27fcd9..5af816a1d8f 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -646,6 +646,131 @@ describe('#getQueryParams', () => { }); }); }); + + describe('when using ACLSearchParams search', () => { + it('no ACLSearchParams provided', () => { + const result: Result = getQueryParams({ + registry, + ACLSearchParams: {}, + }); + expect(result.query.bool.filter[1]).toEqual(undefined); + }); + + it('workspacesSearchOperator prvided as "OR"', () => { + const result: Result = getQueryParams({ + registry, + workspaces: ['foo'], + workspacesSearchOperator: 'OR', + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must_not: [ + { + exists: { + field: 'workspaces', + }, + }, + { + exists: { + field: 'permissions', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + must: [ + { + term: { + workspaces: 'foo', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + + it('principals and permissionModes provided in ACLSearchParams', () => { + const result: Result = getQueryParams({ + registry, + ACLSearchParams: { + principals: { + users: ['user-foo'], + groups: ['group-foo'], + }, + permissionModes: ['read'], + }, + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must_not: [ + { + exists: { + field: 'workspaces', + }, + }, + { + exists: { + field: 'permissions', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user-foo'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group-foo'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + }); }); describe('namespaces property', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index b78c5a03299..abbef0850db 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -34,6 +34,8 @@ type KueryNode = any; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; +import { SavedObjectsFindOptions } from '../../../types'; +import { ACL } from '../../../permission_control/acl'; /** * Gets the types based on the type. Uses mappings to support @@ -166,6 +168,8 @@ interface QueryParams { hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; workspaces?: string[]; + workspacesSearchOperator?: 'AND' | 'OR'; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -223,6 +227,8 @@ export function getQueryParams({ hasReference, kueryNode, workspaces, + workspacesSearchOperator = 'AND', + ACLSearchParams, }: QueryParams) { const types = getTypes( registry, @@ -247,17 +253,6 @@ export function getQueryParams({ ], }; - if (workspaces) { - bool.filter.push({ - bool: { - should: workspaces.map((workspace) => { - return getClauseForWorkspace(workspace); - }), - minimum_should_match: 1, - }, - }); - } - if (search) { const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search); const simpleQueryStringClause = getSimpleQueryStringClause({ @@ -279,6 +274,69 @@ export function getQueryParams({ } } + const ACLSearchParamsShouldClause: any = []; + + if (ACLSearchParams) { + if (ACLSearchParams.permissionModes?.length && ACLSearchParams.principals) { + const permissionDSL = ACL.generateGetPermittedSavedObjectsQueryDSL( + ACLSearchParams.permissionModes, + ACLSearchParams.principals + ); + ACLSearchParamsShouldClause.push(permissionDSL.query); + } + } + + if (workspaces?.length) { + if (workspacesSearchOperator === 'OR') { + ACLSearchParamsShouldClause.push({ + bool: { + should: workspaces.map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } else { + bool.filter.push({ + bool: { + should: workspaces.map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } + } + + if (ACLSearchParamsShouldClause.length) { + bool.filter.push({ + bool: { + should: [ + /** + * Return those objects without workspaces field and permissions field to keep find API backward compatible + */ + { + bool: { + must_not: [ + { + exists: { + field: 'workspaces', + }, + }, + { + exists: { + field: 'permissions', + }, + }, + ], + }, + }, + ...ACLSearchParamsShouldClause, + ], + }, + }); + } + return { query: { bool } }; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index df6109eb9d0..fa431157663 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -34,6 +34,7 @@ import { IndexMapping } from '../../../mappings'; import { getQueryParams } from './query_params'; import { getSortingParams } from './sorting_params'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; +import { SavedObjectsFindOptions } from '../../../types'; type KueryNode = any; @@ -53,6 +54,8 @@ interface GetSearchDslOptions { }; kueryNode?: KueryNode; workspaces?: string[]; + workspacesSearchOperator?: 'AND' | 'OR'; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } export function getSearchDsl( @@ -73,6 +76,8 @@ export function getSearchDsl( hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, } = options; if (!type) { @@ -96,6 +101,8 @@ export function getSearchDsl( hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, }), ...getSortingParams(mappings, type, sortField, sortOrder), }; diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 4ab6978a3dc..d21421dbe25 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -45,6 +45,7 @@ export { } from './import/types'; import { SavedObject } from '../../types'; +import { Principals } from './permission_control/acl'; type KueryNode = any; @@ -112,6 +113,15 @@ export interface SavedObjectsFindOptions { preference?: string; /** If specified, will only retrieve objects that are in the workspaces */ workspaces?: string[]; + /** By default the operator will be 'AND' */ + workspacesSearchOperator?: 'AND' | 'OR'; + /** + * The params here will be combined with bool clause and is used for filtering with ACL structure. + */ + ACLSearchParams?: { + principals?: Principals; + permissionModes?: string[]; + }; } /** diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 91db9f37fc4..ccc69de18bc 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -16,3 +16,10 @@ export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; + +export enum WorkspacePermissionMode { + Read = 'read', + Write = 'write', + LibraryRead = 'library_read', + LibraryWrite = 'library_write', +} diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 061d0f3c406..4ef7aeb13d5 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -5,7 +5,7 @@ import { WorkspaceAttribute } from 'src/core/types'; import * as osdTestServer from '../../../../core/test_helpers/osd_server'; -import { WORKSPACE_TYPE } from '../../../../core/server'; +import { WORKSPACE_TYPE, Permissions } from '../../../../core/server'; const omitId = (object: T): Omit => { const { id, ...others } = object; @@ -18,7 +18,7 @@ const testWorkspace: WorkspaceAttribute = { description: 'test_workspace_description', }; -describe('workspace service', () => { +describe('workspace service api integration test', () => { let root: ReturnType; let opensearchServer: osdTestServer.TestOpenSearchUtils; let osd: osdTestServer.TestOpenSearchDashboardsUtils; @@ -30,6 +30,11 @@ describe('workspace service', () => { workspace: { enabled: true, }, + savedObjects: { + permission: { + enabled: false, + }, + }, migrations: { skip: false }, }, }, @@ -260,3 +265,107 @@ describe('workspace service', () => { }); }); }); + +describe('workspace service api integration test when savedObjects.permission.enabled equal true', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + let osd: osdTestServer.TestOpenSearchDashboardsUtils; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + savedObjects: { + permission: { + enabled: true, + }, + }, + migrations: { skip: false }, + }, + }, + }); + opensearchServer = await startOpenSearch(); + osd = await startOpenSearchDashboards(); + root = osd.root; + }); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + describe('Workspace CRUD APIs', () => { + afterEach(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + const savedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ + WORKSPACE_TYPE, + ]); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + // this will delete reserved workspace + savedObjectsRepository.delete(WORKSPACE_TYPE, item.id) + ) + ); + }); + it('create', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: { invalid_type: { users: ['foo'] } }, + }) + .expect(400); + + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: { read: { users: ['foo'] } }, + }) + .expect(200); + + expect(result.body.success).toEqual(true); + expect(typeof result.body.result.id).toBe('string'); + expect( + ( + await osd.coreStart.savedObjects + .createInternalRepository([WORKSPACE_TYPE]) + .get<{ permissions: Permissions }>(WORKSPACE_TYPE, result.body.result.id) + ).permissions + ).toEqual({ read: { users: ['foo'] } }); + }); + it('update', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + const updateResult = await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omitId(testWorkspace), + }, + permissions: { write: { users: ['foo'] } }, + }) + .expect(200); + expect(updateResult.body.result).toBe(true); + + expect( + ( + await osd.coreStart.savedObjects + .createInternalRepository([WORKSPACE_TYPE]) + .get<{ permissions: Permissions }>(WORKSPACE_TYPE, result.body.result.id) + ).permissions + ).toEqual({ write: { users: ['foo'] } }); + }); + }); +}); diff --git a/src/plugins/workspace/server/permission_control/client.mock.ts b/src/plugins/workspace/server/permission_control/client.mock.ts new file mode 100644 index 00000000000..687e93de1d7 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.mock.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { SavedObjectsPermissionControlContract } from './client'; + +export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = { + validate: jest.fn(), + batchValidate: jest.fn(), + getPrincipalsOfObjects: jest.fn(), + setup: jest.fn(), +}; diff --git a/src/plugins/workspace/server/permission_control/client.test.ts b/src/plugins/workspace/server/permission_control/client.test.ts new file mode 100644 index 00000000000..4d041cc7df5 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.test.ts @@ -0,0 +1,200 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { loggerMock } from '@osd/logging/target/mocks'; +import { SavedObjectsPermissionControl } from './client'; +import { + httpServerMock, + httpServiceMock, + savedObjectsClientMock, +} from '../../../../core/server/mocks'; +import * as utilsExports from '../utils'; + +describe('PermissionControl', () => { + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({ + users: ['bar'], + })); + const mockAuth = httpServiceMock.createAuth(); + + it('validate should return error when no saved objects can be found', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [], + }); + const result = await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest(), + { id: 'foo', type: 'dashboard' }, + ['read'] + ); + expect(result.success).toEqual(false); + }); + + it('validate should return error when bulkGet return error', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + error: { + error: 'error_bar', + message: 'error_bar', + statusCode: 500, + }, + }, + ], + }); + const errorResult = await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest(), + { id: 'foo', type: 'dashboard' }, + ['read'] + ); + expect(errorResult.success).toEqual(false); + expect(errorResult.error).toEqual('error_bar'); + }); + + it('validate should return success normally', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + }, + { + id: 'bar', + type: 'dashboard', + references: [], + attributes: {}, + permissions: { + read: { + users: ['bar'], + }, + }, + }, + ], + }); + const batchValidateResult = await permissionControlClient.batchValidate( + httpServerMock.createOpenSearchDashboardsRequest(), + [], + ['read'] + ); + expect(batchValidateResult.success).toEqual(true); + expect(batchValidateResult.result).toEqual(true); + }); + + it('should return false and log not permitted saved objects', async () => { + const logger = loggerMock.create(); + const permissionControlClient = new SavedObjectsPermissionControl(logger); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + }, + { + id: 'bar', + type: 'dashboard', + references: [], + attributes: {}, + permissions: { + read: { + users: ['foo'], + }, + }, + }, + ], + }); + const batchValidateResult = await permissionControlClient.batchValidate( + httpServerMock.createOpenSearchDashboardsRequest(), + [], + ['read'] + ); + expect(batchValidateResult.success).toEqual(true); + expect(batchValidateResult.result).toEqual(false); + expect(logger.debug).toHaveBeenCalledTimes(1); + }); + + describe('getPrincipalsFromRequest', () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + permissionControlClient.setup(getScopedClient, mockAuth); + + it('should return normally when calling getPrincipalsFromRequest', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const result = permissionControlClient.getPrincipalsFromRequest(mockRequest); + expect(result.users).toEqual(['bar']); + }); + }); + + describe('validateSavedObjectsACL', () => { + it("should return true if saved objects don't have permissions property", () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + expect( + permissionControlClient.validateSavedObjectsACL([{ type: 'workspace', id: 'foo' }], {}, []) + ).toBe(true); + }); + it('should return true if principals permitted to saved objects', () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + expect( + permissionControlClient.validateSavedObjectsACL( + [{ type: 'workspace', id: 'foo', permissions: { write: { users: ['bar'] } } }], + { users: ['bar'] }, + ['write'] + ) + ).toBe(true); + }); + it('should return false and log saved objects if not permitted', () => { + const logger = loggerMock.create(); + const permissionControlClient = new SavedObjectsPermissionControl(logger); + expect( + permissionControlClient.validateSavedObjectsACL( + [{ type: 'workspace', id: 'foo', permissions: { write: { users: ['bar'] } } }], + { users: ['foo'] }, + ['write'] + ) + ).toBe(false); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringMatching( + /Authorization failed, principals:.*has no.*permissions on the requested saved object:.*foo/ + ) + ); + }); + }); +}); diff --git a/src/plugins/workspace/server/permission_control/client.ts b/src/plugins/workspace/server/permission_control/client.ts new file mode 100644 index 00000000000..bdc67f83091 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -0,0 +1,178 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + ACL, + SavedObjectsBulkGetObject, + SavedObjectsServiceStart, + Logger, + OpenSearchDashboardsRequest, + Principals, + SavedObject, + WORKSPACE_TYPE, + Permissions, + HttpAuth, +} from '../../../../core/server'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants'; +import { getPrincipalsFromRequest } from '../utils'; + +export type SavedObjectsPermissionControlContract = Pick< + SavedObjectsPermissionControl, + keyof SavedObjectsPermissionControl +>; + +export type SavedObjectsPermissionModes = string[]; + +export class SavedObjectsPermissionControl { + private readonly logger: Logger; + private _getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + private auth?: HttpAuth; + /** + * Returns a saved objects client that is able to: + * 1. Read objects whose type is `workspace` because workspace is a hidden type and the permission control client will need to get the metadata of a specific workspace to do the permission check. + * 2. Bypass saved objects permission control wrapper because the permission control client is a dependency of the wrapper to provide the ACL validation capability. It will run into infinite loop if not bypass. + * @param request + * @returns SavedObjectsContract + */ + private getScopedClient(request: OpenSearchDashboardsRequest) { + return this._getScopedClient?.(request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + includedHiddenTypes: [WORKSPACE_TYPE], + }); + } + + constructor(logger: Logger) { + this.logger = logger; + } + + private async bulkGetSavedObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ) { + return (await this.getScopedClient?.(request)?.bulkGet(savedObjects))?.saved_objects || []; + } + public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient'], auth: HttpAuth) { + this._getScopedClient = getScopedClient; + this.auth = auth; + } + public async validate( + request: OpenSearchDashboardsRequest, + savedObject: SavedObjectsBulkGetObject, + permissionModes: SavedObjectsPermissionModes + ) { + return await this.batchValidate(request, [savedObject], permissionModes); + } + + private logNotPermitted( + savedObjects: Array, 'id' | 'type' | 'workspaces' | 'permissions'>>, + principals: Principals, + permissionModes: SavedObjectsPermissionModes + ) { + this.logger.debug( + `Authorization failed, principals: ${JSON.stringify( + principals + )} has no [${permissionModes}] permissions on the requested saved object: ${JSON.stringify( + savedObjects.map((savedObject) => ({ + id: savedObject.id, + type: savedObject.type, + workspaces: savedObject.workspaces, + permissions: savedObject.permissions, + })) + )}` + ); + } + + public getPrincipalsFromRequest(request: OpenSearchDashboardsRequest) { + return getPrincipalsFromRequest(request, this.auth); + } + + /** + * Validates the permissions for a collection of saved objects based on their Access Control Lists (ACL). + * This method checks whether the provided principals have the specified permission modes for each saved object. + * If any saved object lacks the required permissions, the function logs details of unauthorized access. + * + * @remarks + * If a saved object doesn't have an ACL (e.g., config objects), it is considered as having the required permissions. + * The function logs detailed information when unauthorized access is detected, including the list of denied saved objects. + */ + public validateSavedObjectsACL( + savedObjects: Array, 'id' | 'type' | 'workspaces' | 'permissions'>>, + principals: Principals, + permissionModes: SavedObjectsPermissionModes + ) { + const notPermittedSavedObjects: Array, + 'id' | 'type' | 'workspaces' | 'permissions' + >> = []; + const hasPermissionToAllObjects = savedObjects.every((savedObject) => { + // for object that doesn't contain ACL like config, return true + if (!savedObject.permissions) { + return true; + } + + const aclInstance = new ACL(savedObject.permissions); + const hasPermission = aclInstance.hasPermission(permissionModes, principals); + if (!hasPermission) { + notPermittedSavedObjects.push({ + id: savedObject.id, + type: savedObject.type, + workspaces: savedObject.workspaces, + permissions: savedObject.permissions, + }); + } + return hasPermission; + }); + if (!hasPermissionToAllObjects) { + this.logNotPermitted(notPermittedSavedObjects, principals, permissionModes); + } + return hasPermissionToAllObjects; + } + + /** + * Performs batch validation to check if the current request has access to specified saved objects + * with the given permission modes. + * @param request + * @param savedObjects + * @param permissionModes + * @returns + */ + public async batchValidate( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[], + permissionModes: SavedObjectsPermissionModes + ) { + const savedObjectsGet = await this.bulkGetSavedObjects(request, savedObjects); + if (!savedObjectsGet.length) { + return { + success: false, + error: i18n.translate('savedObjects.permission.notFound', { + defaultMessage: 'Can not find target saved objects.', + }), + }; + } + + if (savedObjectsGet.some((item) => item.error)) { + return { + success: false, + error: savedObjectsGet + .filter((item) => item.error) + .map((item) => item.error?.error) + .join('\n'), + }; + } + + const principals = this.getPrincipalsFromRequest(request); + const hasPermissionToAllObjects = this.validateSavedObjectsACL( + savedObjectsGet, + principals, + permissionModes + ); + return { + success: true, + result: hasPermissionToAllObjects, + }; + } +} diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts new file mode 100644 index 00000000000..684f754ce9d --- /dev/null +++ b/src/plugins/workspace/server/plugin.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/server/mocks'; +import { WorkspacePlugin } from './plugin'; + +describe('Workspace server plugin', () => { + it('#setup', async () => { + let value; + const setupMock = coreMock.createSetup(); + const initializerContextConfigMock = coreMock.createPluginInitializerContext({ + enabled: true, + }); + setupMock.capabilities.registerProvider.mockImplementationOnce((fn) => (value = fn())); + const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); + await workspacePlugin.setup(setupMock); + expect(value).toMatchInlineSnapshot(` + Object { + "workspaces": Object { + "enabled": true, + "permissionEnabled": true, + }, + } + `); + }); +}); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index e846470210c..df1ece8ef46 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -3,24 +3,38 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { PluginInitializerContext, CoreSetup, Plugin, Logger, CoreStart, + SharedGlobalConfig, } from '../../../core/server'; +import { + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, +} from '../common/constants'; +import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; -import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; -import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; +import { + SavedObjectsPermissionControl, + SavedObjectsPermissionControlContract, +} from './permission_control/client'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; private client?: IWorkspaceClientImpl; private workspaceConflictControl?: WorkspaceConflictSavedObjectsClientWrapper; + private permissionControl?: SavedObjectsPermissionControlContract; + private readonly globalConfig$: Observable; + private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -43,10 +57,13 @@ export class WorkspacePlugin implements Plugin ({ + workspaces: { + enabled: true, + permissionEnabled: isPermissionControlEnabled, + }, + })); + return { client: this.client, }; @@ -74,8 +115,10 @@ export class WorkspacePlugin implements Plugin { - const { attributes } = req.body; + const { attributes, permissions } = req.body; + const principals = permissionControlClient?.getPrincipalsFromRequest(req); + const createPayload: Omit = attributes; + + if (isPermissionControlEnabled) { + createPayload.permissions = permissions; + // Assign workspace owner to current user + if (!!principals?.users?.length) { + const acl = new ACL(permissions); + const currentUserId = principals.users[0]; + [WorkspacePermissionMode.Write, WorkspacePermissionMode.LibraryWrite].forEach( + (permissionMode) => { + if (!acl.hasPermission([permissionMode], { users: [currentUserId] })) { + acl.addPermission([permissionMode], { users: [currentUserId] }); + } + } + ); + createPayload.permissions = acl.getPermissions(); + } + } const result = await client.create( { @@ -103,7 +147,7 @@ export function registerRoutes({ request: req, logger, }, - attributes + createPayload ); return res.ok({ body: result }); }) @@ -117,12 +161,13 @@ export function registerRoutes({ }), body: schema.object({ attributes: workspaceAttributesSchema, + permissions: schema.maybe(workspacePermissions), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { id } = req.params; - const { attributes } = req.body; + const { attributes, permissions } = req.body; const result = await client.update( { @@ -131,7 +176,10 @@ export function registerRoutes({ logger, }, id, - attributes + { + ...attributes, + ...(isPermissionControlEnabled ? { permissions } : {}), + } ); return res.ok({ body: result }); }) diff --git a/src/plugins/workspace/server/saved_objects/index.ts b/src/plugins/workspace/server/saved_objects/index.ts index 51653c50681..e47be61b0cd 100644 --- a/src/plugins/workspace/server/saved_objects/index.ts +++ b/src/plugins/workspace/server/saved_objects/index.ts @@ -4,3 +4,4 @@ */ export { workspace } from './workspace'; +export { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts new file mode 100644 index 00000000000..b6ea26456f0 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,596 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createTestServers, + TestOpenSearchUtils, + TestOpenSearchDashboardsUtils, + TestUtils, +} from '../../../../../core/test_helpers/osd_server'; +import { + SavedObjectsErrorHelpers, + WORKSPACE_TYPE, + ISavedObjectsRepository, + SavedObjectsClientContract, +} from '../../../../../core/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import * as utilsExports from '../../utils'; + +const repositoryKit = (() => { + const savedObjects: Array<{ type: string; id: string }> = []; + return { + create: async ( + repository: ISavedObjectsRepository, + ...params: Parameters + ) => { + let result; + try { + result = params[2]?.id ? await repository.get(params[0], params[2].id) : undefined; + } catch (_e) { + // ignore error when get failed + } + if (!result) { + result = await repository.create(...params); + } + savedObjects.push(result); + return result; + }, + clearAll: async (repository: ISavedObjectsRepository) => { + for (let i = 0; i < savedObjects.length; i++) { + try { + await repository.delete(savedObjects[i].type, savedObjects[i].id); + } catch (_e) { + // Ignore delete error + } + } + }, + }; +})(); + +const permittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); +const notPermittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); + +describe('WorkspaceSavedObjectsClientWrapper', () => { + let internalSavedObjectsRepository: ISavedObjectsRepository; + let servers: TestUtils; + let opensearchServer: TestOpenSearchUtils; + let osd: TestOpenSearchDashboardsUtils; + let permittedSavedObjectedClient: SavedObjectsClientContract; + let notPermittedSavedObjectedClient: SavedObjectsClientContract; + + beforeAll(async function () { + servers = createTestServers({ + adjustTimeout: (t) => { + jest.setTimeout(t); + }, + settings: { + osd: { + workspace: { + enabled: true, + }, + savedObjects: { + permission: { + enabled: true, + }, + }, + migrations: { skip: false }, + }, + }, + }); + opensearchServer = await servers.startOpenSearch(); + osd = await servers.startOpenSearchDashboards(); + + internalSavedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ + WORKSPACE_TYPE, + ]); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'workspace', + {}, + { + id: 'workspace-1', + permissions: { + library_read: { users: ['foo'] }, + library_write: { users: ['foo'] }, + }, + } + ); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + workspaces: ['workspace-1'], + } + ); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + id: 'acl-controlled-dashboard-2', + permissions: { + read: { users: ['foo'], groups: [] }, + write: { users: ['foo'], groups: [] }, + }, + } + ); + + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation((request) => { + if (request === notPermittedRequest) { + return { users: ['bar'] }; + } + return { users: ['foo'] }; + }); + + permittedSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient(permittedRequest); + notPermittedSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient( + notPermittedRequest + ); + }); + + afterAll(async () => { + await repositoryKit.clearAll(internalSavedObjectsRepository); + await opensearchServer.stop(); + await osd.stop(); + + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockRestore(); + }); + + describe('get', () => { + it('should throw forbidden error when user not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1'); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2'); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should return consistent dashboard when user permitted', async () => { + expect( + (await permittedSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1')).error + ).toBeUndefined(); + expect( + (await permittedSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')).error + ).toBeUndefined(); + }); + }); + + describe('bulkGet', () => { + it('should throw forbidden error when user not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1' }, + ]); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'acl-controlled-dashboard-2' }, + ]); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should return consistent dashboard when user permitted', async () => { + expect( + ( + await permittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1' }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await permittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'acl-controlled-dashboard-2' }, + ]) + ).saved_objects.length + ).toEqual(1); + }); + }); + + describe('find', () => { + it('should throw not authorized error when user not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isNotAuthorizedError(error)).toBe(true); + }); + + it('should return consistent inner workspace data when user permitted', async () => { + const result = await permittedSavedObjectedClient.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', () => { + it('should throw forbidden error when workspace not permitted and create called', async () => { + let error; + try { + await notPermittedSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to create saved objects into permitted workspaces after create called', async () => { + const createResult = await permittedSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + expect(createResult.error).toBeUndefined(); + await permittedSavedObjectedClient.delete('dashboard', createResult.id); + }); + + it('should throw forbidden error when create with override', async () => { + let error; + try { + await notPermittedSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + } + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to create with override', async () => { + const createResult = await permittedSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.error).toBeUndefined(); + }); + }); + + describe('bulkCreate', () => { + it('should throw forbidden error when workspace not permitted and bulkCreate called', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkCreate([{ type: 'dashboard', attributes: {} }], { + workspaces: ['workspace-1'], + }); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to create saved objects into permitted workspaces after bulkCreate called', async () => { + const objectId = new Date().getTime().toString(16).toUpperCase(); + const result = await permittedSavedObjectedClient.bulkCreate( + [{ type: 'dashboard', attributes: {}, id: objectId }], + { + workspaces: ['workspace-1'], + } + ); + expect(result.saved_objects.length).toEqual(1); + await permittedSavedObjectedClient.delete('dashboard', objectId); + }); + + it('should throw forbidden error when create with override', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkCreate( + [ + { + id: 'inner-workspace-dashboard-1', + type: 'dashboard', + attributes: {}, + }, + ], + { + overwrite: true, + workspaces: ['workspace-1'], + } + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to bulk create with override', async () => { + const createResult = await permittedSavedObjectedClient.bulkCreate( + [ + { + id: 'inner-workspace-dashboard-1', + type: 'dashboard', + attributes: {}, + }, + ], + { + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.saved_objects).toHaveLength(1); + }); + }); + + describe('update', () => { + it('should throw forbidden error when data not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.update( + 'dashboard', + 'inner-workspace-dashboard-1', + {} + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.update('dashboard', 'acl-controlled-dashboard-2', {}); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should update saved objects for permitted workspaces', async () => { + expect( + (await permittedSavedObjectedClient.update('dashboard', 'inner-workspace-dashboard-1', {})) + .error + ).toBeUndefined(); + expect( + (await permittedSavedObjectedClient.update('dashboard', 'acl-controlled-dashboard-2', {})) + .error + ).toBeUndefined(); + }); + }); + + describe('bulkUpdate', () => { + it('should throw forbidden error when data not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkUpdate( + [{ type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }], + {} + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.bulkUpdate( + [{ type: 'dashboard', id: 'acl-controlled-dashboard-2', attributes: {} }], + {} + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should bulk update saved objects for permitted workspaces', async () => { + expect( + ( + await permittedSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await permittedSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + }); + }); + + describe('delete', () => { + it('should throw forbidden error when data not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.delete('dashboard', 'inner-workspace-dashboard-1'); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.delete('dashboard', 'acl-controlled-dashboard-2'); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should be able to delete permitted data', async () => { + const createResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + + await permittedSavedObjectedClient.delete('dashboard', createResult.id); + + let error; + try { + error = await permittedSavedObjectedClient.get('dashboard', createResult.id); + } catch (e) { + error = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); + }); + + it('should be able to delete acl controlled permitted data', async () => { + const createResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + permissions: { + read: { users: ['foo'] }, + write: { users: ['foo'] }, + }, + } + ); + + await permittedSavedObjectedClient.delete('dashboard', createResult.id); + + let error; + try { + error = await permittedSavedObjectedClient.get('dashboard', createResult.id); + } catch (e) { + error = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); + }); + }); + + describe('deleteByWorkspace', () => { + it('should throw forbidden error when workspace not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.deleteByWorkspace('workspace-1'); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should be able to delete all data in permitted workspace', async () => { + const deleteWorkspaceId = 'workspace-to-delete'; + await repositoryKit.create( + internalSavedObjectsRepository, + 'workspace', + {}, + { + id: deleteWorkspaceId, + permissions: { + library_read: { users: ['foo'] }, + library_write: { users: ['foo'] }, + }, + } + ); + const dashboardIds = [ + 'inner-delete-workspace-dashboard-1', + 'inner-delete-workspace-dashboard-2', + ]; + await Promise.all( + dashboardIds.map((dashboardId) => + repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + id: dashboardId, + workspaces: [deleteWorkspaceId], + } + ) + ) + ); + + expect( + ( + await permittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: [deleteWorkspaceId], + }) + ).total + ).toBe(2); + + await permittedSavedObjectedClient.deleteByWorkspace(deleteWorkspaceId, { refresh: true }); + + expect( + ( + await permittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: [deleteWorkspaceId], + }) + ).total + ).toBe(0); + }); + }); +}); 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 new file mode 100644 index 00000000000..186ecda0d8b --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,668 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsErrorHelpers } from '../../../../core/server'; +import { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; + +const generateWorkspaceSavedObjectsClientWrapper = () => { + const savedObjectsStore = [ + { + type: 'dashboard', + id: 'foo', + workspaces: ['workspace-1'], + attributes: { + bar: 'baz', + }, + permissions: {}, + }, + { + type: 'dashboard', + id: 'not-permitted-dashboard', + workspaces: ['not-permitted-workspace'], + attributes: { + bar: 'baz', + }, + permissions: {}, + }, + { + type: 'dashboard', + id: 'dashboard-with-empty-workspace-property', + workspaces: [], + attributes: { + bar: 'baz', + }, + permissions: {}, + }, + { type: 'workspace', id: 'workspace-1', attributes: { name: 'Workspace - 1' } }, + { + type: 'workspace', + id: 'not-permitted-workspace', + attributes: { name: 'Not permitted workspace' }, + }, + ]; + const clientMock = { + get: jest.fn().mockImplementation(async (type, id) => { + if (type === 'config') { + return { + type: 'config', + }; + } + if (id === 'unknown-error-dashboard') { + throw new Error('Unknown error'); + } + return ( + savedObjectsStore.find((item) => item.type === type && item.id === id) || + SavedObjectsErrorHelpers.createGenericNotFoundError() + ); + }), + create: jest.fn(), + bulkCreate: jest.fn(), + checkConflicts: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + bulkUpdate: jest.fn(), + bulkGet: jest.fn().mockImplementation((savedObjectsToFind) => { + return { + saved_objects: savedObjectsStore.filter((item) => + savedObjectsToFind.find( + (itemToFind) => itemToFind.type === item.type && itemToFind.id === item.id + ) + ), + }; + }), + find: jest.fn(), + deleteByWorkspace: jest.fn(), + }; + const requestMock = {}; + const wrapperOptions = { + client: clientMock, + request: requestMock, + typeRegistry: {}, + }; + const permissionControlMock = { + setup: jest.fn(), + validate: jest.fn().mockImplementation((_request, { id }) => { + return { + success: true, + result: !id.startsWith('not-permitted'), + }; + }), + validateSavedObjectsACL: jest.fn(), + batchValidate: jest.fn(), + getPrincipalsFromRequest: jest.fn().mockImplementation(() => ({ users: ['user-1'] })), + }; + const wrapper = new WorkspaceSavedObjectsClientWrapper(permissionControlMock); + const scopedClientMock = { + find: jest.fn().mockImplementation(async () => ({ + saved_objects: [{ id: 'workspace-1', type: 'workspace' }], + })), + }; + wrapper.setScopedClient(() => scopedClientMock); + return { + wrapper: wrapper.wrapperFactory(wrapperOptions), + clientMock, + scopedClientMock, + permissionControlMock, + requestMock, + }; +}; + +describe('WorkspaceSavedObjectsClientWrapper', () => { + describe('wrapperFactory', () => { + describe('delete', () => { + it('should throw permission error if not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.delete('dashboard', 'not-permitted-dashboard'); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it("should throw permission error if deleting saved object's workspace property is empty", async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.delete('dashboard', 'dashboard-with-empty-workspace-property'); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call client.delete with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const deleteArgs = ['dashboard', 'foo', { force: true }] as const; + await wrapper.delete(...deleteArgs); + expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); + }); + }); + + describe('update', () => { + it('should throw permission error if not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.update('dashboard', 'not-permitted-dashboard', { + bar: 'foo', + }); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it("should throw permission error if updating saved object's workspace property is empty", async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.update('dashboard', 'dashboard-with-empty-workspace-property', { + bar: 'foo', + }); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call client.update with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const updateArgs = [ + 'workspace', + 'foo', + { + bar: 'foo', + }, + {}, + ] as const; + await wrapper.update(...updateArgs); + expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); + }); + }); + + describe('bulk update', () => { + it('should throw permission error if not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkUpdate([ + { type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }, + ]); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call client.bulkUpdate with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const objectsToUpdate = [{ type: 'dashboard', id: 'foo', attributes: { bar: 'baz' } }]; + await wrapper.bulkUpdate(objectsToUpdate, {}); + expect(clientMock.bulkUpdate).toHaveBeenCalledWith(objectsToUpdate, {}); + }); + }); + + describe('bulk create', () => { + it('should throw workspace permission error if passed workspaces but not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: false }); + let errorCatched; + try { + await wrapper.bulkCreate([{ type: 'dashboard', id: 'new-dashboard', attributes: {} }], { + workspaces: ['not-permitted-workspace'], + }); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it("should throw permission error if overwrite and not permitted on object's workspace and object", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: false }); + let errorCatched; + try { + await wrapper.bulkCreate( + [{ type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }], + { + overwrite: true, + } + ); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it('should throw error if unable to get object when override', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: false }); + let errorCatched; + try { + await wrapper.bulkCreate( + [{ type: 'dashboard', id: 'unknown-error-dashboard', attributes: { bar: 'baz' } }], + { + overwrite: true, + } + ); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toBe('Unknown error'); + }); + it('should call client.bulkCreate with arguments if some objects not found', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const objectsToBulkCreate = [ + { type: 'dashboard', id: 'new-dashboard', attributes: { bar: 'baz' } }, + { type: 'dashboard', id: 'not-found', attributes: { bar: 'foo' } }, + ]; + await wrapper.bulkCreate(objectsToBulkCreate, { + overwrite: true, + }); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + }); + }); + it('should call client.bulkCreate with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const objectsToBulkCreate = [ + { type: 'dashboard', id: 'new-dashboard', attributes: { bar: 'baz' } }, + ]; + await wrapper.bulkCreate(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + }); + }); + + describe('create', () => { + it('should throw workspace permission error if passed workspaces but not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.create('dashboard', 'new-dashboard', { + workspaces: ['not-permitted-workspace'], + }); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it("should throw permission error if overwrite and not permitted on object's workspace and object", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.create( + 'dashboard', + { foo: 'bar' }, + { + id: 'not-permitted-dashboard', + overwrite: true, + } + ); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it('should call client.create with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.create( + 'dashboard', + { foo: 'bar' }, + { + id: 'foo', + overwrite: true, + } + ); + expect(clientMock.create).toHaveBeenCalledWith( + 'dashboard', + { foo: 'bar' }, + { + id: 'foo', + overwrite: true, + } + ); + }); + }); + describe('get', () => { + it('should return saved object if no need to validate permission', async () => { + const { wrapper, permissionControlMock } = generateWorkspaceSavedObjectsClientWrapper(); + const result = await wrapper.get('config', 'config-1'); + expect(result).toEqual({ type: 'config' }); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); + it("should call permission validate with object's workspace and throw permission error", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.get('dashboard', 'not-permitted-dashboard'); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { + type: 'workspace', + id: 'not-permitted-workspace', + }, + ['library_read', 'library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call permission validateSavedObjectsACL with object', async () => { + const { wrapper, permissionControlMock } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.get('dashboard', 'not-permitted-dashboard'); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'dashboard', + id: 'not-permitted-dashboard', + }), + ], + { users: ['user-1'] }, + ['read', 'write'] + ); + }); + it('should call client.get and return result with arguments if permitted', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: true }); + const getArgs = ['workspace', 'foo', {}] as const; + const result = await wrapper.get(...getArgs); + expect(clientMock.get).toHaveBeenCalledWith(...getArgs); + expect(result).toMatchInlineSnapshot(`[Error: Not Found]`); + }); + }); + describe('bulk get', () => { + it("should call permission validate with object's workspace and throw permission error", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkGet([{ type: 'dashboard', id: 'not-permitted-dashboard' }]); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { + type: 'workspace', + id: 'not-permitted-workspace', + }, + ['library_read', 'library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call permission validateSavedObjectsACL with object', async () => { + const { wrapper, permissionControlMock } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkGet([{ type: 'dashboard', id: 'not-permitted-dashboard' }]); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'dashboard', + id: 'not-permitted-dashboard', + }), + ], + { users: ['user-1'] }, + ['write', 'read'] + ); + }); + it('should call client.bulkGet and return result with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + + await wrapper.bulkGet( + [ + { + type: 'dashboard', + id: 'foo', + }, + ], + {} + ); + expect(clientMock.bulkGet).toHaveBeenCalledWith( + [ + { + type: 'dashboard', + id: 'foo', + }, + ], + {} + ); + }); + }); + describe('find', () => { + it('should call client.find with ACLSearchParams for workspace type', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'workspace', + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'workspace', + ACLSearchParams: { + principals: { + users: ['user-1'], + }, + permissionModes: ['read', 'write'], + }, + }); + }); + it('should call client.find with only read permission if find workspace and permissionModes provided', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'workspace', + ACLSearchParams: { + permissionModes: ['read'], + }, + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'workspace', + ACLSearchParams: { + principals: { + users: ['user-1'], + }, + permissionModes: ['read'], + }, + }); + }); + it('should throw workspace permission error if provided workspaces not permitted', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + errorCatched = await wrapper.find({ + type: 'dashboard', + workspaces: ['not-permitted-workspace'], + }); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it('should remove not permitted workspace and call client.find with arguments', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'dashboard', + workspaces: ['not-permitted-workspace', 'workspace-1'], + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'dashboard', + workspaces: ['workspace-1'], + ACLSearchParams: {}, + }); + }); + it('should find permitted workspaces with filtered permission modes', async () => { + const { wrapper, scopedClientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'dashboard', + ACLSearchParams: { + permissionModes: ['read', 'library_read'], + }, + }); + expect(scopedClientMock.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'workspace', + ACLSearchParams: { + permissionModes: ['library_read'], + principals: { users: ['user-1'] }, + }, + }) + ); + }); + it('should call client.find with arguments if not workspace type and no options.workspace', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'dashboard', + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'dashboard', + ACLSearchParams: { + permissionModes: ['read', 'write'], + principals: { users: ['user-1'] }, + }, + }); + }); + }); + + describe('deleteByWorkspace', () => { + it('should call permission validate with workspace and throw workspace permission error if not permitted', async () => { + const { + wrapper, + requestMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.deleteByWorkspace('not-permitted-workspace'); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { id: 'not-permitted-workspace', type: 'workspace' }, + ['library_write'] + ); + }); + + it('should call client.deleteByWorkspace if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + + await wrapper.deleteByWorkspace('workspace-1', {}); + expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('workspace-1', {}); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..30c1c91c422 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -0,0 +1,537 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { + OpenSearchDashboardsRequest, + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsDeleteOptions, + SavedObjectsFindOptions, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateResponse, + SavedObjectsBulkUpdateOptions, + WORKSPACE_TYPE, + SavedObjectsErrorHelpers, + SavedObjectsServiceStart, + SavedObjectsClientContract, + SavedObjectsDeleteByWorkspaceOptions, +} from '../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WorkspacePermissionMode, +} from '../../common/constants'; + +// Can't throw unauthorized for now, the page will be refreshed if unauthorized +const generateWorkspacePermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('workspace.permission.invalidate', { + defaultMessage: 'Invalid workspace permission', + }) + ) + ); + +const generateSavedObjectsPermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('saved_objects.permission.invalidate', { + defaultMessage: 'Invalid saved objects permission', + }) + ) + ); + +const intersection = (...args: T[][]) => { + const occursCountMap: { [key: string]: number } = {}; + for (let i = 0; i < args.length; i++) { + new Set(args[i]).forEach((key) => { + occursCountMap[key] = (occursCountMap[key] || 0) + 1; + }); + } + return Object.keys(occursCountMap).filter((key) => occursCountMap[key] === args.length); +}; + +const getDefaultValuesForEmpty = (values: T[] | undefined, defaultValues: T[]) => { + return !values || values.length === 0 ? defaultValues : values; +}; + +export class WorkspaceSavedObjectsClientWrapper { + private getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + + private async validateObjectsPermissions( + objects: Array>, + request: OpenSearchDashboardsRequest, + permissionModes: WorkspacePermissionMode[] + ) { + // PermissionModes here is an array which is merged by workspace type required permission and other saved object required permission. + // So we only need to do one permission check no matter its type. + for (const { id, type } of objects) { + const validateResult = await this.permissionControl.validate( + request, + { + type, + id, + }, + permissionModes + ); + if (!validateResult?.result) { + return false; + } + } + return true; + } + + // validate if the `request` has the specified permission(`permissionMode`) to the given `workspaceIds` + private validateMultiWorkspacesPermissions = async ( + workspacesIds: string[], + request: OpenSearchDashboardsRequest, + permissionModes: WorkspacePermissionMode[] + ) => { + // for attributes and options passed in this function, the num of workspaces may be 0.This case should not be passed permission check. + if (workspacesIds.length === 0) { + return false; + } + const workspaces = workspacesIds.map((id) => ({ id, type: WORKSPACE_TYPE })); + return await this.validateObjectsPermissions(workspaces, request, permissionModes); + }; + + private validateAtLeastOnePermittedWorkspaces = async ( + workspaces: string[] | undefined, + request: OpenSearchDashboardsRequest, + permissionModes: WorkspacePermissionMode[] + ) => { + // for attributes and options passed in this function, the num of workspaces attribute may be 0.This case should not be passed permission check. + if (!workspaces || workspaces.length === 0) { + return false; + } + for (const workspaceId of workspaces) { + const validateResult = await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + permissionModes + ); + if (validateResult?.result) { + return true; + } + } + return false; + }; + + /** + * check if the type include workspace + * Workspace permission check is totally different from object permission check. + * @param type + * @returns + */ + private isRelatedToWorkspace(type: string | string[]): boolean { + return type === WORKSPACE_TYPE || (Array.isArray(type) && type.includes(WORKSPACE_TYPE)); + } + + private async validateWorkspacesAndSavedObjectsPermissions( + savedObject: Pick, + request: OpenSearchDashboardsRequest, + workspacePermissionModes: WorkspacePermissionMode[], + objectPermissionModes: WorkspacePermissionMode[], + validateAllWorkspaces = true + ) { + /** + * + * Checks if the provided saved object lacks both workspaces and permissions. + * If a saved object lacks both attributes, it implies that the object is neither associated + * with any workspaces nor has permissions defined by itself. Such objects are considered "public" + * and will be excluded from permission checks. + * + **/ + if (!savedObject.workspaces && !savedObject.permissions) { + return true; + } + + let hasPermission = false; + // Check permission based on object's workspaces. + // If workspacePermissionModes is passed with an empty array, we need to skip this validation and continue to validate object ACL. + if (savedObject.workspaces && workspacePermissionModes.length > 0) { + const workspacePermissionValidator = validateAllWorkspaces + ? this.validateMultiWorkspacesPermissions + : this.validateAtLeastOnePermittedWorkspaces; + hasPermission = await workspacePermissionValidator( + savedObject.workspaces, + request, + workspacePermissionModes + ); + } + // If already has permissions based on workspaces, we don't need to check object's ACL(defined by permissions attribute) + // So return true immediately + if (hasPermission) { + return true; + } + // Check permission based on object's ACL(defined by permissions attribute) + if (savedObject.permissions) { + hasPermission = await this.permissionControl.validateSavedObjectsACL( + [savedObject], + this.permissionControl.getPrincipalsFromRequest(request), + objectPermissionModes + ); + } + return hasPermission; + } + + private getWorkspaceTypeEnabledClient(request: OpenSearchDashboardsRequest) { + return this.getScopedClient?.(request, { + includedHiddenTypes: [WORKSPACE_TYPE], + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + }) as SavedObjectsClientContract; + } + + public setScopedClient(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this.getScopedClient = getScopedClient; + } + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const deleteWithWorkspacePermissionControl = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + const objectToDeleted = await wrapperOptions.client.get(type, id, options); + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + objectToDeleted, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write] + )) + ) { + throw generateSavedObjectsPermissionError(); + } + return await wrapperOptions.client.delete(type, id, options); + }; + + /** + * validate if can update`objectToUpdate`, means a user should either + * have `Write` permission on the `objectToUpdate` itself or `LibraryWrite` permission + * to any of the workspaces the `objectToUpdate` associated with. + **/ + const validateUpdateWithWorkspacePermission = async ( + objectToUpdate: SavedObject + ): Promise => { + return await this.validateWorkspacesAndSavedObjectsPermissions( + objectToUpdate, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write], + false + ); + }; + + const updateWithWorkspacePermissionControl = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + const objectToUpdate = await wrapperOptions.client.get(type, id, options); + const permitted = await validateUpdateWithWorkspacePermission(objectToUpdate); + if (!permitted) { + throw generateSavedObjectsPermissionError(); + } + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkUpdateWithWorkspacePermissionControl = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + const objectsToUpdate = await wrapperOptions.client.bulkGet(objects, options); + + for (const object of objectsToUpdate.saved_objects) { + const permitted = await validateUpdateWithWorkspacePermission(object); + if (!permitted) { + throw generateSavedObjectsPermissionError(); + } + } + + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + + const bulkCreateWithWorkspacePermissionControl = async ( + objects: Array>, + options: SavedObjectsCreateOptions = {} + ): Promise> => { + const hasTargetWorkspaces = options?.workspaces && options.workspaces.length > 0; + + if ( + hasTargetWorkspaces && + !(await this.validateMultiWorkspacesPermissions( + options.workspaces ?? [], + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite] + )) + ) { + throw generateWorkspacePermissionError(); + } + + /** + * + * If target workspaces parameter doesn't exists and `overwrite` is true, we need to check + * if it has permission to the object itself(defined by the object ACL) or it has permission + * to any of the workspaces that the object associates with. + * + */ + if (!hasTargetWorkspaces && options.overwrite) { + for (const object of objects) { + const { type, id } = object; + if (id) { + let rawObject; + try { + rawObject = await wrapperOptions.client.get(type, id); + } catch (error) { + // If object is not found, we will skip the validation of this object. + if (SavedObjectsErrorHelpers.isNotFoundError(error as Error)) { + continue; + } else { + throw error; + } + } + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + rawObject, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write], + false + )) + ) { + throw generateWorkspacePermissionError(); + } + } + } + } + + return await wrapperOptions.client.bulkCreate(objects, options); + }; + + const createWithWorkspacePermissionControl = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + const hasTargetWorkspaces = options?.workspaces && options.workspaces.length > 0; + + if ( + hasTargetWorkspaces && + !(await this.validateMultiWorkspacesPermissions( + options?.workspaces ?? [], + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite] + )) + ) { + throw generateWorkspacePermissionError(); + } + + /** + * + * If target workspaces parameter doesn't exists, `options.id` was exists and `overwrite` is true, + * we need to check if it has permission to the object itself(defined by the object ACL) or + * it has permission to any of the workspaces that the object associates with. + * + */ + if ( + options?.overwrite && + options.id && + !hasTargetWorkspaces && + !(await this.validateWorkspacesAndSavedObjectsPermissions( + await wrapperOptions.client.get(type, options.id), + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write], + false + )) + ) { + throw generateWorkspacePermissionError(); + } + + return await wrapperOptions.client.create(type, attributes, options); + }; + + const getWithWorkspacePermissionControl = async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const objectToGet = await wrapperOptions.client.get(type, id, options); + + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + objectToGet, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write], + false + )) + ) { + throw generateSavedObjectsPermissionError(); + } + return objectToGet; + }; + + const bulkGetWithWorkspacePermissionControl = async ( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const objectToBulkGet = await wrapperOptions.client.bulkGet(objects, options); + + for (const object of objectToBulkGet.saved_objects) { + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + object, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write, WorkspacePermissionMode.Read], + false + )) + ) { + throw generateSavedObjectsPermissionError(); + } + } + + return objectToBulkGet; + }; + + const findWithWorkspacePermissionControl = async ( + options: SavedObjectsFindOptions + ) => { + const principals = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); + if (!options.ACLSearchParams) { + options.ACLSearchParams = {}; + } + + if (this.isRelatedToWorkspace(options.type)) { + /** + * + * This case is for finding workspace saved objects, will use passed permissionModes + * and override passed principals from request to get all readable workspaces. + * + */ + options.ACLSearchParams.permissionModes = getDefaultValuesForEmpty( + options.ACLSearchParams.permissionModes, + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write] + ); + options.ACLSearchParams.principals = principals; + } else { + /** + * Workspace is a hidden type so that we need to + * initialize a new saved objects client with workspace enabled to retrieve all the workspaces with permission. + */ + const permittedWorkspaceIds = ( + await this.getWorkspaceTypeEnabledClient(wrapperOptions.request).find({ + type: WORKSPACE_TYPE, + perPage: 999, + ACLSearchParams: { + principals, + /** + * The permitted workspace ids will be passed to the options.workspaces + * or options.ACLSearchParams.workspaces. These two were indicated the saved + * objects data inner specific workspaces. We use Library related permission here. + * For outside passed permission modes, it may contains other permissions. Add a intersection + * here to make sure only Library related permission modes will be used. + */ + permissionModes: getDefaultValuesForEmpty( + options.ACLSearchParams.permissionModes + ? intersection(options.ACLSearchParams.permissionModes, [ + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.LibraryWrite, + ]) + : [], + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite] + ), + }, + }) + ).saved_objects.map((item) => item.id); + + if (options.workspaces) { + const permittedWorkspaces = options.workspaces.filter((item) => + permittedWorkspaceIds.includes(item) + ); + if (!permittedWorkspaces.length) { + /** + * If user does not have any one workspace access + * deny the request + */ + throw SavedObjectsErrorHelpers.decorateNotAuthorizedError( + new Error( + i18n.translate('workspace.permission.invalidate', { + defaultMessage: 'Invalid workspace permission', + }) + ) + ); + } + + /** + * Overwrite the options.workspaces when user has access on partial workspaces. + */ + options.workspaces = permittedWorkspaces; + } else { + /** + * If no workspaces present, find all the docs that + * ACL matches read / write / user passed permission + */ + options.ACLSearchParams.permissionModes = getDefaultValuesForEmpty( + options.ACLSearchParams.permissionModes, + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write] + ); + options.ACLSearchParams.principals = principals; + } + } + + return await wrapperOptions.client.find(options); + }; + + const deleteByWorkspaceWithPermissionControl = async ( + workspace: string, + options: SavedObjectsDeleteByWorkspaceOptions = {} + ) => { + if ( + !(await this.validateMultiWorkspacesPermissions([workspace], wrapperOptions.request, [ + WorkspacePermissionMode.LibraryWrite, + ])) + ) { + throw generateWorkspacePermissionError(); + } + + return await wrapperOptions.client.deleteByWorkspace(workspace, options); + }; + + return { + ...wrapperOptions.client, + get: getWithWorkspacePermissionControl, + checkConflicts: wrapperOptions.client.checkConflicts, + find: findWithWorkspacePermissionControl, + bulkGet: bulkGetWithWorkspacePermissionControl, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + create: createWithWorkspacePermissionControl, + bulkCreate: bulkCreateWithWorkspacePermissionControl, + delete: deleteWithWorkspacePermissionControl, + update: updateWithWorkspacePermissionControl, + bulkUpdate: bulkUpdateWithWorkspacePermissionControl, + deleteByWorkspace: deleteByWorkspaceWithPermissionControl, + }; + }; + + constructor(private readonly permissionControl: SavedObjectsPermissionControlContract) {} +} diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 29e8747c761..2973ea4dbc3 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -11,8 +11,13 @@ import { CoreSetup, WorkspaceAttribute, SavedObjectsServiceStart, + Permissions, } from '../../../core/server'; +export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { + permissions?: Permissions; +} + export interface WorkspaceFindOptions { page?: number; perPage?: number; @@ -52,7 +57,7 @@ export interface IWorkspaceClientImpl { */ create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit ): Promise>; /** * List workspaces @@ -90,7 +95,7 @@ export interface IWorkspaceClientImpl { update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Omit ): Promise>; /** * Delete a given workspace @@ -118,6 +123,11 @@ export type IResponse = error?: string; }; +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} + export interface WorkspacePluginSetup { client: IWorkspaceClientImpl; } diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 119b8889f71..1f6c3e58f12 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateRandomId } from './utils'; +import { AuthStatus } from '../../../core/server'; +import { httpServerMock, httpServiceMock } from '../../../core/server/mocks'; +import { generateRandomId, getPrincipalsFromRequest } from './utils'; describe('workspace utils', () => { + const mockAuth = httpServiceMock.createAuth(); it('should generate id with the specified size', () => { expect(generateRandomId(6)).toHaveLength(6); }); @@ -18,4 +21,56 @@ describe('workspace utils', () => { } expect(ids.size).toBe(NUM_OF_ID); }); + + it('should return empty map when request do not have authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.unknown, + state: { + authInfo: { + user_name: 'bar', + backend_roles: ['foo'], + }, + }, + }); + const result = getPrincipalsFromRequest(mockRequest, mockAuth); + expect(result).toEqual({}); + }); + + it('should return normally when request has authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.authenticated, + state: { + authInfo: { + user_name: 'bar', + backend_roles: ['foo'], + }, + }, + }); + const result = getPrincipalsFromRequest(mockRequest, mockAuth); + expect(result.users).toEqual(['bar']); + expect(result.groups).toEqual(['foo']); + }); + + it('should throw error when request is not authenticated', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.unauthenticated, + state: {}, + }); + expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow('NOT_AUTHORIZED'); + }); + + it('should throw error when authentication status is not expected', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + // @ts-ignore + status: 'foo', + state: {}, + }); + expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow( + 'UNEXPECTED_AUTHORIZATION_STATUS' + ); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 89bfabd5265..1c8d73953af 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -4,6 +4,14 @@ */ import crypto from 'crypto'; +import { + AuthStatus, + HttpAuth, + OpenSearchDashboardsRequest, + Principals, + PrincipalType, +} from '../../../core/server'; +import { AuthInfo } from './types'; /** * Generate URL friendly random ID @@ -11,3 +19,34 @@ import crypto from 'crypto'; export const generateRandomId = (size: number) => { return crypto.randomBytes(size).toString('base64url').slice(0, size); }; + +export const getPrincipalsFromRequest = ( + request: OpenSearchDashboardsRequest, + auth?: HttpAuth +): Principals => { + const payload: Principals = {}; + const authInfoResp = auth?.get(request); + if (authInfoResp?.status === AuthStatus.unknown) { + /** + * Login user have access to all the workspaces when no authentication is presented. + */ + return payload; + } + + if (authInfoResp?.status === AuthStatus.authenticated) { + const authInfo = authInfoResp?.state as { authInfo: AuthInfo } | null; + if (authInfo?.authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.authInfo.backend_roles; + } + if (authInfo?.authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.authInfo.user_name]; + } + return payload; + } + + if (authInfoResp?.status === AuthStatus.unauthenticated) { + throw new Error('NOT_AUTHORIZED'); + } + + throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); +}; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 092f3e528d4..144ad65de9f 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -4,7 +4,7 @@ */ import { i18n } from '@osd/i18n'; -import type { +import { SavedObject, SavedObjectsClientContract, CoreSetup, @@ -12,7 +12,13 @@ import type { SavedObjectsServiceStart, } from '../../../core/server'; import { WORKSPACE_TYPE } from '../../../core/server'; -import { IWorkspaceClientImpl, WorkspaceFindOptions, IResponse, IRequestDetail } from './types'; +import { + IWorkspaceClientImpl, + WorkspaceFindOptions, + IResponse, + IRequestDetail, + WorkspaceAttributeWithPermission, +} from './types'; import { workspace } from './saved_objects'; import { generateRandomId } from './utils'; import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; @@ -67,10 +73,10 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } public async create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit ): ReturnType { try { - const attributes = payload; + const { permissions, ...attributes } = payload; const id = generateRandomId(WORKSPACE_ID_SIZE); const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( @@ -88,6 +94,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { attributes, { id, + permissions, } ); return { @@ -153,9 +160,9 @@ export class WorkspaceClient implements IWorkspaceClientImpl { public async update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Omit ): Promise> { - const attributes = payload; + const { permissions, ...attributes } = payload; try { const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); @@ -172,7 +179,12 @@ export class WorkspaceClient implements IWorkspaceClientImpl { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } } - await client.update>(WORKSPACE_TYPE, id, attributes, {}); + await client.create>(WORKSPACE_TYPE, attributes, { + id, + permissions, + overwrite: true, + version: workspaceInDB.version, + }); return { success: true, result: true,