From e0c9e8747a9d16a617437e84aecec760dc3d14f2 Mon Sep 17 00:00:00 2001 From: tygao Date: Wed, 6 Mar 2024 14:21:44 +0800 Subject: [PATCH] add permission control service for saved objects and workspace saved objects client wrapper (#230) * feat: add basic workspace saved objects client wrapper Signed-off-by: Lin Wang * feat: add unit test (#2) Signed-off-by: SuZhou-Joe * feat: update client wrapper Signed-off-by: tygao * feat: init permission control in workspace plugin Signed-off-by: Lin Wang * Support disable permission check on workspace (#228) * support disable permission check for workspace Signed-off-by: Hailong Cui * fix typos Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * feat: add ACLSearchParams consumer in repository (#3) Signed-off-by: SuZhou-Joe * fix: ACLSearchParams missing in search dsl Signed-off-by: Lin Wang * test: add integration test for workspace saved objects client wrapper Signed-off-by: Lin Wang * style: add empty line under license Signed-off-by: Lin Wang * test: enable workspace permission control for integration tests Signed-off-by: Lin Wang * feat: add workspace into includeHiddenTypes (#249) * feat: add workspace into includeHiddenTypes of client wrapper and permission control client Signed-off-by: SuZhou-Joe * fix: hiddenType side effect Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * fix workspace client wrapper integration tests Signed-off-by: Lin Wang * add permissions fields to workspace CRUD APIs Signed-off-by: Lin Wang * Move WorkspacePermissionMode inside workspace plugin Signed-off-by: Lin Wang * Address pr comments Signed-off-by: Lin Wang * Remove ACLSearchParams in public SavedObjectsFindOptions Signed-off-by: Lin Wang * Remove lodash and Add default permissionModes Signed-off-by: Lin Wang * feat: address concerns on ensureRawRequest (#4) * feat: address concerns on ensureRawRequest Signed-off-by: SuZhou-Joe * feat: add check for empty array Signed-off-by: SuZhou-Joe * feat: make find api backward compatible Signed-off-by: SuZhou-Joe * feat: remove useless code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * Update annotations and error Signed-off-by: Lin Wang * Add unit tests for worksapce saved objects client wrapper Signed-off-by: Lin Wang * Remove getPrincipalsOfObjects in permission Signed-off-by: Lin Wang * Fix permissionEnabled flag missed in workspace plugin setup test Signed-off-by: Lin Wang * Change back to Not Authorized error Signed-off-by: Lin Wang * Fix unit tests for query_params and plugin setup Signed-off-by: Lin Wang * Fix unittests in workspace server utils Signed-off-by: Lin Wang * feat: add workspacesSearchOperators to decouple ACLSearchParams Signed-off-by: SuZhou-Joe * feat: update test cases Signed-off-by: SuZhou-Joe * feat: optimize test cases Signed-off-by: SuZhou-Joe * feat: optimize comment Signed-off-by: SuZhou-Joe * feat: omit defaultSearchOperator in public savedobjetcs client Signed-off-by: SuZhou-Joe * feat: omit workspacesSearchOperator field Signed-off-by: SuZhou-Joe --------- Signed-off-by: Lin Wang Signed-off-by: SuZhou-Joe Signed-off-by: tygao Signed-off-by: Hailong Cui Co-authored-by: Lin Wang Co-authored-by: SuZhou-Joe Co-authored-by: Hailong Cui --- src/core/public/index.ts | 1 + .../saved_objects/saved_objects_client.ts | 6 +- src/core/server/index.ts | 5 + src/core/server/saved_objects/index.ts | 8 + .../saved_objects/service/lib/repository.ts | 4 + .../lib/search_dsl/query_params.test.ts | 125 ++++ .../service/lib/search_dsl/query_params.ts | 80 ++- .../service/lib/search_dsl/search_dsl.ts | 7 + src/core/server/saved_objects/types.ts | 10 + src/plugins/workspace/common/constants.ts | 7 + src/plugins/workspace/config.ts | 5 +- .../workspace/public/workspace_client.test.ts | 69 ++ .../workspace/public/workspace_client.ts | 20 +- .../server/integration_tests/routes.test.ts | 70 ++ .../server/permission_control/client.mock.ts | 12 + .../server/permission_control/client.test.ts | 123 ++++ .../server/permission_control/client.ts | 185 ++++++ src/plugins/workspace/server/plugin.test.ts | 4 +- src/plugins/workspace/server/plugin.ts | 44 +- src/plugins/workspace/server/routes/index.ts | 59 +- .../workspace/server/saved_objects/index.ts | 1 + ...space_saved_objects_client_wrapper.test.ts | 528 ++++++++++++++++ ...space_saved_objects_client_wrapper.test.ts | 597 ++++++++++++++++++ .../workspace_saved_objects_client_wrapper.ts | 549 ++++++++++++++++ src/plugins/workspace/server/types.ts | 14 + src/plugins/workspace/server/utils.test.ts | 53 +- src/plugins/workspace/server/utils.ts | 39 ++ 27 files changed, 2600 insertions(+), 25 deletions(-) create mode 100644 src/plugins/workspace/server/permission_control/client.mock.ts create mode 100644 src/plugins/workspace/server/permission_control/client.test.ts create mode 100644 src/plugins/workspace/server/permission_control/client.ts create mode 100644 src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts create mode 100644 src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts create mode 100644 src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 9b545049cad4..993618c08fe9 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -105,6 +105,7 @@ export { StringValidationRegex, StringValidationRegexString, WorkspaceObject, + WorkspaceAttribute, } from '../types'; export { diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 243b4b7c8434..f415e9c58596 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 736f12ab28fe..f3edadf21895 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -322,6 +322,11 @@ export { importSavedObjectsFromStream, resolveSavedObjectsImportErrors, SavedObjectsDeleteByWorkspaceOptions, + ACL, + Principals, + TransformedPermission, + PrincipalType, + Permissions, } from './saved_objects'; export { diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 06b2b65fd184..11809c5b88c9 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -84,3 +84,11 @@ export { export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; + +export { + Permissions, + ACL, + Principals, + TransformedPermission, + PrincipalType, +} from './permission_control/acl'; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 42d45ea73e16..5a9e0696e840 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -823,6 +823,8 @@ export class SavedObjectsRepository { filter, preference, workspaces, + workspacesSearchOperator, + ACLSearchParams, } = options; if (!type && !typeToNamespacesMap) { @@ -897,6 +899,8 @@ export class SavedObjectsRepository { hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, }), }, }; 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 a47bc27fcd92..5af816a1d8f5 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 0d056f624bf1..b8fd28fe46c2 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 @@ -187,6 +189,8 @@ interface QueryParams { hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; workspaces?: string[]; + workspacesSearchOperator?: 'AND' | 'OR'; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -244,6 +248,8 @@ export function getQueryParams({ hasReference, kueryNode, workspaces, + workspacesSearchOperator = 'AND', + ACLSearchParams, }: QueryParams) { const types = getTypes( registry, @@ -268,17 +274,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({ @@ -300,6 +295,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 df6109eb9d0a..fa4311576638 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 4ab6978a3dc1..d21421dbe253 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 2aa86aed2e44..fe702116b8ef 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -9,3 +9,10 @@ export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; 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/config.ts b/src/plugins/workspace/config.ts index 79412f5c02ee..70c87ac00cfc 100644 --- a/src/plugins/workspace/config.ts +++ b/src/plugins/workspace/config.ts @@ -7,6 +7,9 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), + permission: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }); -export type ConfigSchema = TypeOf; +export type WorkspacePluginConfigType = TypeOf; diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts index c18ed3db64e7..9fe901fecc88 100644 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { WorkspacePermissionMode } from '../common/constants'; import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; import { WorkspaceClient } from './workspace_client'; @@ -104,6 +105,40 @@ describe('#WorkspaceClient', () => { }); }); + it('#create with permissions', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.create( + { + name: 'foo', + }, + [{ type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }] + ); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { + method: 'POST', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + permissions: [ + { type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }, + ], + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + it('#delete', async () => { const { workspaceClient, httpSetupMock } = getWorkspaceClient(); httpSetupMock.fetch.mockResolvedValue({ @@ -209,4 +244,38 @@ describe('#WorkspaceClient', () => { }); expect(workspaceMock.workspaceList$.getValue()).toEqual([]); }); + + it('#update with permissions', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.update( + 'foo', + { + name: 'foo', + }, + [{ type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }] + ); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'PUT', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + permissions: [ + { type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }, + ], + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); }); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index dc07a83ab1bd..f7a3313bdd06 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -11,6 +11,7 @@ import { WorkspaceAttribute, WorkspacesSetup, } from '../../../core/public'; +import { WorkspacePermissionMode } from '../common/constants'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -30,6 +31,15 @@ type IResponse = error?: string; }; +type WorkspacePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Read + | WorkspacePermissionMode.Write + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); + interface WorkspaceFindOptions { page?: number; perPage?: number; @@ -183,14 +193,16 @@ export class WorkspaceClient { * @returns {Promise>>} id of the new created workspace */ public async create( - attributes: Omit - ): Promise>> { + attributes: Omit, + permissions?: WorkspacePermissionItem[] + ): Promise> { const path = this.getPath(); const result = await this.safeFetch(path, { method: 'POST', body: JSON.stringify({ attributes, + permissions, }), }); @@ -268,11 +280,13 @@ export class WorkspaceClient { */ public async update( id: string, - attributes: Partial + attributes: Partial, + permissions?: WorkspacePermissionItem[] ): Promise> { const path = this.getPath(id); const body = { attributes, + permissions, }; const result = await this.safeFetch(path, { diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 21d6f155a927..caec12ad78dc 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -6,6 +6,7 @@ import { WorkspaceAttribute } from 'src/core/types'; import * as osdTestServer from '../../../../core/test_helpers/osd_server'; import { WORKSPACE_TYPE } from '../../../../core/server'; +import { WorkspacePermissionItem } from '../types'; const omitId = (object: T): Omit => { const { id, ...others } = object; @@ -29,6 +30,9 @@ describe('workspace service', () => { osd: { workspace: { enabled: true, + permission: { + enabled: false, + }, }, migrations: { skip: false }, }, @@ -80,6 +84,39 @@ describe('workspace service', () => { expect(result.body.success).toEqual(true); expect(typeof result.body.result.id).toBe('string'); }); + it('create with permissions', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: [{ type: 'invalid-type', userId: 'foo', modes: ['read'] }], + }) + .expect(400); + + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: [{ type: 'user', userId: 'foo', modes: ['read'] }], + }) + .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: WorkspacePermissionItem[] }>(WORKSPACE_TYPE, result.body.result.id) + ).attributes.permissions + ).toEqual([ + { + modes: ['read'], + type: 'user', + userId: 'foo', + }, + ]); + }); it('get', async () => { const result = await osdTestServer.request .post(root, `/api/workspaces`) @@ -120,6 +157,39 @@ describe('workspace service', () => { expect(getResult.body.success).toEqual(true); expect(getResult.body.result.name).toEqual('updated'); }); + it('update with permissions', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: [{ type: 'user', userId: 'foo', modes: ['read'] }], + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omitId(testWorkspace), + }, + permissions: [{ type: 'user', userId: 'foo', modes: ['write'] }], + }) + .expect(200); + + expect( + ( + await osd.coreStart.savedObjects + .createInternalRepository([WORKSPACE_TYPE]) + .get<{ permissions: WorkspacePermissionItem[] }>(WORKSPACE_TYPE, result.body.result.id) + ).attributes.permissions + ).toEqual([ + { + modes: ['write'], + type: 'user', + userId: 'foo', + }, + ]); + }); it('delete', async () => { const result: any = await osdTestServer.request .post(root, `/api/workspaces`) 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 000000000000..687e93de1d71 --- /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 000000000000..e05e299c153b --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.test.ts @@ -0,0 +1,123 @@ +/* + * 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); + }); + + 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']); + }); + }); +}); 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 000000000000..bad46eb156a6 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -0,0 +1,185 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + ACL, + TransformedPermission, + 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; + 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); + } + + 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(savedObject); + } + 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 deniedObjects: Array< + Pick & { + workspaces?: string[]; + permissions?: Permissions; + } + > = []; + const hasPermissionToAllObjects = savedObjectsGet.every((item) => { + // for object that doesn't contain ACL like config, return true + if (!item.permissions) { + return true; + } + const aclInstance = new ACL(item.permissions); + const hasPermission = aclInstance.hasPermission(permissionModes, principals); + if (!hasPermission) { + deniedObjects.push({ + id: item.id, + type: item.type, + workspaces: item.workspaces, + permissions: item.permissions, + }); + } + return hasPermission; + }); + if (!hasPermissionToAllObjects) { + this.logger.debug( + `Authorization failed, principals: ${JSON.stringify( + principals + )} has no [${permissionModes}] permissions on the requested saved object: ${JSON.stringify( + deniedObjects + )}` + ); + } + return { + success: true, + result: hasPermissionToAllObjects, + }; + } +} diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 32b4b23a9f59..c448fcf209f9 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -11,7 +11,8 @@ describe('Workspace server plugin', () => { let value; const setupMock = coreMock.createSetup(); const initializerContextConfigMock = coreMock.createPluginInitializerContext({ - workspace: { + enabled: true, + permission: { enabled: true, }, }); @@ -22,6 +23,7 @@ describe('Workspace server plugin', () => { Object { "workspaces": Object { "enabled": true, + "permissionEnabled": true, }, } `); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 145aacaf9bb8..3b267d7092c0 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { PluginInitializerContext, CoreSetup, @@ -13,16 +15,23 @@ import { 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 { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; +import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; -import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; -import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; +import { + SavedObjectsPermissionControl, + SavedObjectsPermissionControlContract, +} from './permission_control/client'; +import { WorkspacePluginConfigType } from '../config'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; private client?: IWorkspaceClientImpl; private workspaceConflictControl?: WorkspaceConflictSavedObjectsClientWrapper; + private permissionControl?: SavedObjectsPermissionControlContract; + private readonly config$: Observable; + private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -42,10 +51,14 @@ export class WorkspacePlugin implements Plugin(); } public async setup(core: CoreSetup) { this.logger.debug('Setting up Workspaces service'); + const config: WorkspacePluginConfigType = await this.config$.pipe(first()).toPromise(); + const isPermissionControlEnabled = + config.permission.enabled === undefined ? true : config.permission.enabled; this.client = new WorkspaceClient(core, this.logger); @@ -67,13 +80,34 @@ export class WorkspacePlugin implements Plugin ({ workspaces: { enabled: true } })); + core.capabilities.registerProvider(() => ({ + workspaces: { + enabled: true, + permissionEnabled: isPermissionControlEnabled, + }, + })); return { client: this.client, @@ -82,8 +116,10 @@ export class WorkspacePlugin implements Plugin { - const { attributes } = req.body; + const { attributes, permissions: permissionsInRequest } = req.body; + const authInfo = permissionControlClient?.getPrincipalsFromRequest(req); + let permissions: WorkspacePermissionItem[] = []; + if (permissionsInRequest) { + permissions = Array.isArray(permissionsInRequest) + ? permissionsInRequest + : [permissionsInRequest]; + } + + // Assign workspace owner to current user + if (!!authInfo?.users?.length) { + permissions.push({ + type: 'user', + userId: authInfo.users[0], + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }); + } const result = await client.create( { @@ -108,6 +152,7 @@ export function registerRoutes({ }, { ...attributes, + ...(permissions.length ? { permissions } : {}), } ); return res.ok({ body: result }); @@ -122,12 +167,19 @@ export function registerRoutes({ }), body: schema.object({ attributes: workspaceAttributesSchema, + permissions: schema.maybe( + schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)]) + ), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { id } = req.params; - const { attributes } = req.body; + const { attributes, permissions } = req.body; + let finalPermissions: WorkspacePermissionItem[] = []; + if (permissions) { + finalPermissions = Array.isArray(permissions) ? permissions : [permissions]; + } const result = await client.update( { @@ -138,6 +190,7 @@ export function registerRoutes({ id, { ...attributes, + ...(finalPermissions.length ? { permissions: finalPermissions } : {}), } ); 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 51653c50681e..e47be61b0cd2 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 000000000000..9f4f46b4dc99 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,528 @@ +/* + * 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, + 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); + }); + }); +}); 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 000000000000..6b40f6e60fa0 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,597 @@ +/* + * 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: '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', + }; + } + 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); + wrapper.setScopedClient(() => ({ + find: jest.fn().mockImplementation(async () => ({ + saved_objects: [{ id: 'workspace-1', type: 'workspace' }], + })), + })); + return { + wrapper: wrapper.wrapperFactory(wrapperOptions), + clientMock, + 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 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 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 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, + workspaces: ['workspace-1'], + }); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + }); + 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 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', + workspaces: ['workspace-1'], + workspacesSearchOperator: 'OR', + 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 000000000000..c515f555fa4b --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -0,0 +1,549 @@ +/* + * 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, + SavedObjectsDeleteByWorkspaceOptions, + SavedObjectsErrorHelpers, + SavedObjectsServiceStart, + SavedObjectsClientContract, +} 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 formatWorkspacePermissionModeToStringArray( + permission: WorkspacePermissionMode | WorkspacePermissionMode[] + ): string[] { + if (Array.isArray(permission)) { + return permission; + } + + return [permission]; + } + + private async validateObjectsPermissions( + objects: Array>, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) { + // PermissionMode 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, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + 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, + permissionMode: WorkspacePermissionMode | 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, permissionMode); + }; + + private validateAtLeastOnePermittedWorkspaces = async ( + workspaces: string[] | undefined, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | 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, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + 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 { + /** + * Select all the docs that + * 1. ACL matches read / write / user passed permission OR + * 2. workspaces matches library_read or library_write OR + */ + options.workspaces = permittedWorkspaceIds; + options.workspacesSearchOperator = 'OR'; + 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 29e8747c7618..3539dd76c546 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -12,6 +12,7 @@ import { WorkspaceAttribute, SavedObjectsServiceStart, } from '../../../core/server'; +import { WorkspacePermissionMode } from '../common/constants'; export interface WorkspaceFindOptions { page?: number; @@ -125,3 +126,16 @@ export interface WorkspacePluginSetup { export interface WorkspacePluginStart { client: IWorkspaceClientImpl; } +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} + +export type WorkspacePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Read + | WorkspacePermissionMode.Write + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 119b8889f715..5af40eea9b06 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,52 @@ 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: { + 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: { + 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 89bfabd52657..e51637cd49c3 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 | null; + if (authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.backend_roles; + } + if (authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.user_name]; + } + return payload; + } + + if (authInfoResp?.status === AuthStatus.unauthenticated) { + throw new Error('NOT_AUTHORIZED'); + } + + throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); +};