diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ec01eab952..c47868108004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Refactor data source selector component to include placeholder and add tests ([#6372](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6372)) - [Dynamic Configurations] Improve dynamic configurations by adding cache and simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364)) - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) +- [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365)) ### 🐛 Bug Fixes diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index dbcd0053a5b3..ad934e0a73ae 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -38,6 +38,7 @@ import { SavedObjectsClientContract as SavedObjectsApi, SavedObjectsFindOptions as SavedObjectFindOptionsServer, SavedObjectsMigrationVersion, + SavedObjectsBaseOptions, } from '../../server'; import { SimpleSavedObject } from './simple_saved_object'; @@ -65,7 +66,7 @@ export interface SavedObjectsCreateOptions { /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; references?: SavedObjectReference[]; - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; } /** @@ -83,7 +84,7 @@ export interface SavedObjectsBulkCreateObject extends SavedObjectsC export interface SavedObjectsBulkCreateOptions { /** If a document with the given `id` already exists, overwrite it's contents (default=false). */ overwrite?: boolean; - workspaces?: string[]; + workspaces?: SavedObjectsCreateOptions['workspaces']; } /** @public */ diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index ea944ff3307a..17e9af3c3e1b 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -30,7 +30,7 @@ import Boom from '@hapi/boom'; import { createListStream } from '../../utils/streams'; -import { SavedObjectsClientContract, SavedObject } from '../types'; +import { SavedObjectsClientContract, SavedObject, SavedObjectsBaseOptions } from '../types'; import { fetchNestedDependencies } from './inject_nested_depdendencies'; import { sortObjects } from './sort_objects'; @@ -61,7 +61,7 @@ export interface SavedObjectsExportOptions { /** optional namespace to override the namespace used by the savedObjectsClient. */ namespace?: string; /** optional workspaces to override the workspaces used by the savedObjectsClient. */ - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; } /** @@ -97,7 +97,7 @@ async function fetchObjectsToExport({ exportSizeLimit: number; savedObjectsClient: SavedObjectsClientContract; namespace?: string; - workspaces?: string[]; + workspaces?: SavedObjectsExportOptions['workspaces']; }) { if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts index f36bcf3a8a92..7afab618b1ff 100644 --- a/src/core/server/saved_objects/import/check_conflicts.ts +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -35,6 +35,7 @@ import { SavedObjectsImportError, SavedObjectError, SavedObjectsImportRetry, + SavedObjectsBaseOptions, } from '../types'; interface CheckConflictsParams { @@ -44,7 +45,7 @@ interface CheckConflictsParams { ignoreRegularConflicts?: boolean; retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; } const isUnresolvableConflict = (error: SavedObjectError) => @@ -79,7 +80,7 @@ export async function checkConflicts({ }); const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, { namespace, - workspaces, + ...(workspaces ? { workspaces } : {}), }); const errorMap = checkConflictsResult.errors.reduce( (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts index 1a9e218f169d..fa723d225508 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -660,4 +660,14 @@ describe('#createSavedObjects', () => { }); }); }); + + describe('with a undefined workspaces', () => { + test('calls bulkCreate once with input objects', async () => { + const options = setupParams({ objects: objs }); + setupMockResults(options); + + await createSavedObjects(options); + expect(bulkCreate.mock.calls[0][1]?.hasOwnProperty('workspaces')).toEqual(false); + }); + }); }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index fa471d0d44d9..89b751b3ff28 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -28,7 +28,12 @@ * under the License. */ -import { SavedObject, SavedObjectsClientContract, SavedObjectsImportError } from '../types'; +import { + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsClientContract, + SavedObjectsImportError, +} from '../types'; import { extractErrors } from './extract_errors'; import { CreatedObject } from './types'; import { extractVegaSpecFromSavedObject, updateDataSourceNameInVegaSpec } from './utils'; @@ -42,7 +47,7 @@ interface CreateSavedObjectsParams { overwrite?: boolean; dataSourceId?: string; dataSourceTitle?: string; - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -199,7 +204,7 @@ export const createSavedObjects = async ({ const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { namespace, overwrite, - workspaces, + ...(workspaces ? { workspaces } : {}), }); expectedResults = bulkCreateResponse.saved_objects; } diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 994b7e627189..a243e08f83e0 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -29,7 +29,7 @@ */ import { Readable } from 'stream'; -import { SavedObjectsClientContract, SavedObject } from '../types'; +import { SavedObjectsClientContract, SavedObject, SavedObjectsBaseOptions } from '../types'; import { ISavedObjectTypeRegistry } from '..'; /** @@ -190,7 +190,7 @@ export interface SavedObjectsImportOptions { dataSourceId?: string; dataSourceTitle?: string; /** if specified, will import in given workspaces */ - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; } /** @@ -215,7 +215,7 @@ export interface SavedObjectsResolveImportErrorsOptions { dataSourceId?: string; dataSourceTitle?: string; /** if specified, will import in given workspaces */ - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; } export type CreatedObject = SavedObject & { destinationId?: string }; diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 056b1b795550..b8a020b4ea24 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -70,7 +70,7 @@ export const registerBulkCreateRoute = (router: IRouter) => { : undefined; const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite, - workspaces, + ...(workspaces ? { workspaces } : {}), }); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index 4d22bd244a03..2a7958aa9b78 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -71,7 +71,7 @@ export const registerCreateRoute = (router: IRouter) => { migrationVersion, references, initialNamespaces, - workspaces, + ...(workspaces ? { workspaces } : {}), }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 9325b632e40f..4d9d5b7e8ec3 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -106,7 +106,7 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) exportSizeLimit: maxImportExportSize, includeReferencesDeep, excludeExportDetails, - workspaces, + ...(workspaces ? { workspaces } : {}), }); const docsToExport: string[] = await createPromiseFromStreams([ diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 36fa7c2cd9f5..42b47cf950fc 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -85,7 +85,7 @@ export const registerFindRoute = (router: IRouter) => { fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, namespaces, - workspaces, + ...(workspaces ? { workspaces } : {}), }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index f882596ce529..6b1f025e856d 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -29,7 +29,7 @@ */ import { Permissions } from '../permission_control'; -import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; +import { SavedObjectsMigrationVersion, SavedObjectReference, SavedObject } from '../types'; /** * A raw document as represented directly in the saved object index. @@ -53,7 +53,7 @@ export interface SavedObjectsRawDocSource { updated_at?: string; references?: SavedObjectReference[]; originId?: string; - workspaces?: string[]; + workspaces?: SavedObject['workspaces']; [typeMapping: string]: any; } @@ -71,7 +71,7 @@ interface SavedObjectDoc { version?: string; updated_at?: string; originId?: string; - workspaces?: string[]; + workspaces?: SavedObject['workspaces']; permissions?: Permissions; } 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 abbef0850dba..318768fd83c2 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 @@ -167,7 +167,7 @@ interface QueryParams { defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; - workspaces?: string[]; + workspaces?: SavedObjectsFindOptions['workspaces']; workspacesSearchOperator?: 'AND' | 'OR'; ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } 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 fa4311576638..626fc7efba3e 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 @@ -53,7 +53,7 @@ interface GetSearchDslOptions { id: string; }; kueryNode?: KueryNode; - workspaces?: string[]; + workspaces?: SavedObjectsFindOptions['workspaces']; workspacesSearchOperator?: 'AND' | 'OR'; ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 05069d9d887a..a51a832bdb0a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -69,10 +69,6 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; - /** - * workspaces the new created objects belong to - */ - workspaces?: string[]; /** permission control describe by ACL object */ permissions?: Permissions; } @@ -101,7 +97,7 @@ export interface SavedObjectsBulkCreateObject { /** * workspaces the objects belong to, will only be used when overwrite is enabled. */ - workspaces?: string[]; + workspaces?: SavedObject['workspaces']; } /** diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index d21421dbe253..369cbfd53bf4 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -112,7 +112,7 @@ export interface SavedObjectsFindOptions { /** An optional OpenSearch preference value to be used for the query **/ preference?: string; /** If specified, will only retrieve objects that are in the workspaces */ - workspaces?: string[]; + workspaces?: SavedObjectsBaseOptions['workspaces']; /** By default the operator will be 'AND' */ workspacesSearchOperator?: 'AND' | 'OR'; /** @@ -132,7 +132,7 @@ export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ namespace?: string; /** Specify the workspaces for this operation */ - workspaces?: string[]; + workspaces?: SavedObject['workspaces'] | null; } /** diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index ccc69de18bc5..0eecc20063af 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -23,3 +23,15 @@ export enum WorkspacePermissionMode { LibraryRead = 'library_read', LibraryWrite = 'library_write', } + +export const WORKSPACE_ID_CONSUMER_WRAPPER_ID = 'workspace_id_consumer'; + +/** + * The priority for these wrappers matters: + * 1. WORKSPACE_ID_CONSUMER should be placed before the other two wrappers(smaller than the other two wrappers) as it cost little + * and will append the essential workspaces field into the options, which will be honored by permission control wrapper and conflict wrapper. + * 2. The order of permission wrapper and conflict wrapper does not matter as no dependency between these two wrappers. + */ +export const PRIORITY_FOR_WORKSPACE_ID_CONSUMER_WRAPPER = -2; +export const PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER = 0; +export const PRIORITY_FOR_WORKSPACE_CONFLICT_CONTROL_WRAPPER = -1; diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 684f754ce9dd..0ad72b51b6dc 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -3,8 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { coreMock } from '../../../core/server/mocks'; +import { OnPreRoutingHandler } from 'src/core/server'; +import { coreMock, httpServerMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; +import { getWorkspaceState } from '../../../core/server/utils'; describe('Workspace server plugin', () => { it('#setup', async () => { @@ -24,5 +26,66 @@ describe('Workspace server plugin', () => { }, } `); + expect(setupMock.savedObjects.addClientWrapper).toBeCalledTimes(3); + }); + + it('#proxyWorkspaceTrafficToRealHandler', async () => { + const setupMock = coreMock.createSetup(); + const initializerContextConfigMock = coreMock.createPluginInitializerContext({ + enabled: true, + permission: { + enabled: true, + }, + }); + let onPreRoutingFn: OnPreRoutingHandler = () => httpServerMock.createResponseFactory().ok(); + setupMock.http.registerOnPreRouting.mockImplementation((fn) => { + onPreRoutingFn = fn; + return fn; + }); + const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); + await workspacePlugin.setup(setupMock); + const toolKitMock = httpServerMock.createToolkit(); + + const requestWithWorkspaceInUrl = httpServerMock.createOpenSearchDashboardsRequest({ + path: '/w/foo/app', + }); + onPreRoutingFn(requestWithWorkspaceInUrl, httpServerMock.createResponseFactory(), toolKitMock); + expect(toolKitMock.rewriteUrl).toBeCalledWith('http://localhost/app'); + expect(toolKitMock.next).toBeCalledTimes(0); + expect(getWorkspaceState(requestWithWorkspaceInUrl)).toEqual({ + requestWorkspaceId: 'foo', + }); + + const requestWithoutWorkspaceInUrl = httpServerMock.createOpenSearchDashboardsRequest({ + path: '/app', + }); + onPreRoutingFn( + requestWithoutWorkspaceInUrl, + httpServerMock.createResponseFactory(), + toolKitMock + ); + expect(toolKitMock.next).toBeCalledTimes(1); + }); + + it('#start', async () => { + const setupMock = coreMock.createSetup(); + const startMock = coreMock.createStart(); + const initializerContextConfigMock = coreMock.createPluginInitializerContext({ + enabled: true, + permission: { + enabled: true, + }, + }); + + const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); + await workspacePlugin.setup(setupMock); + await workspacePlugin.start(startMock); + expect(startMock.savedObjects.createSerializer).toBeCalledTimes(1); + }); + + it('#stop', () => { + const initializerContextConfigMock = coreMock.createPluginInitializerContext(); + const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); + workspacePlugin.stop(); }); }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index d86a22296788..db0921483eb4 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -16,17 +16,26 @@ import { import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WORKSPACE_ID_CONSUMER_WRAPPER_ID, + PRIORITY_FOR_WORKSPACE_CONFLICT_CONTROL_WRAPPER, + PRIORITY_FOR_WORKSPACE_ID_CONSUMER_WRAPPER, + PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER, } from '../common/constants'; -import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; +import { + cleanWorkspaceId, + getWorkspaceIdFromUrl, + updateWorkspaceState, +} from '../../../core/server/utils'; import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; import { SavedObjectsPermissionControl, SavedObjectsPermissionControlContract, } from './permission_control/client'; +import { WorkspaceIdConsumerWrapper } from './saved_objects/workspace_id_consumer_wrapper'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; @@ -47,6 +56,9 @@ export class WorkspacePlugin implements Plugin = { + type: 'dashboard', + attributes: {}, + references: [], +}; + +interface WorkspaceAttributes { + id: string; + name?: string; +} + +describe('workspace_id_consumer integration test', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + let createdFooWorkspace: WorkspaceAttributes = { + id: '', + }; + let createdBarWorkspace: WorkspaceAttributes = { + id: '', + }; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + savedObjects: { + permission: { + enabled: true, + }, + }, + migrations: { + skip: false, + }, + }, + }, + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + const createWorkspace = (workspaceAttribute: Omit) => + osdTestServer.request.post(root, `/api/workspaces`).send({ + attributes: workspaceAttribute, + }); + + createdFooWorkspace = await createWorkspace({ + name: 'foo', + }).then((resp) => { + return resp.body.result; + }); + createdBarWorkspace = await createWorkspace({ + name: 'bar', + }).then((resp) => resp.body.result); + }, 30000); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + + const deleteItem = async (object: Pick) => { + expect( + [200, 404].includes( + (await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`)) + .statusCode + ) + ).toEqual(true); + }; + + const getItem = async (object: Pick) => { + return await osdTestServer.request + .get(root, `/api/saved_objects/${object.type}/${object.id}`) + .expect(200); + }; + + const clearFooAndBar = async () => { + await deleteItem({ + type: dashboard.type, + id: 'foo', + }); + await deleteItem({ + type: dashboard.type, + id: 'bar', + }); + }; + + describe('saved objects client related CRUD', () => { + it('create', async () => { + const createResult = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + }) + .expect(200); + + expect(createResult.body.workspaces).toEqual([createdFooWorkspace.id]); + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('bulk create', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultFoo.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, [createdFooWorkspace.id]) + ) + ).toEqual(true); + await Promise.all( + [...createResultFoo.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('checkConflicts when importing ndjson', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/w/${createdBarWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const getResultFoo = await getItem({ + type: dashboard.type, + id: 'foo', + }); + const getResultBar = await getItem({ + type: dashboard.type, + id: 'bar', + }); + + /** + * import with workspaces when conflicts + */ + const importWithWorkspacesResult = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_import?overwrite=false`) + .attach( + 'file', + Buffer.from( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'), + 'utf-8' + ), + 'tmp.ndjson' + ) + .expect(200); + + expect(importWithWorkspacesResult.body.success).toEqual(false); + expect(importWithWorkspacesResult.body.errors.length).toEqual(1); + expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo'); + expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict'); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('find by workspaces', async () => { + const createResultFoo = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/w/${createdBarWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const findResult = await osdTestServer.request + .get(root, `/w/${createdBarWorkspace.id}/api/saved_objects/_find?type=${dashboard.type}`) + .expect(200); + + expect(findResult.body.total).toEqual(1); + expect(findResult.body.saved_objects[0].workspaces).toEqual([createdBarWorkspace.id]); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('import within workspace', async () => { + await clearFooAndBar(); + + const importWithWorkspacesResult = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_import?overwrite=false`) + .attach( + 'file', + Buffer.from( + [ + JSON.stringify({ + ...dashboard, + id: 'bar', + }), + ].join('\n'), + 'utf-8' + ), + 'tmp.ndjson' + ) + .expect(200); + + const findResult = await osdTestServer.request + .get(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_find?type=${dashboard.type}`) + .expect(200); + + expect(importWithWorkspacesResult.body.success).toEqual(true); + expect(findResult.body.saved_objects[0].workspaces).toEqual([createdFooWorkspace.id]); + }); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts index 298d0448031a..838b689328bf 100644 --- a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts @@ -20,8 +20,8 @@ import { const errorContent = (error: Boom.Boom) => error.output.payload; const filterWorkspacesAccordingToSourceWorkspaces = ( - targetWorkspaces?: string[], - baseWorkspaces?: string[] + targetWorkspaces?: SavedObjectsBaseOptions['workspaces'], + baseWorkspaces?: SavedObjectsBaseOptions['workspaces'] ): string[] => targetWorkspaces?.filter((item) => !baseWorkspaces?.includes(item)) || []; export class WorkspaceConflictSavedObjectsClientWrapper { @@ -110,7 +110,7 @@ export class WorkspaceConflictSavedObjectsClientWrapper { }) : []; const objectsConflictWithWorkspace: SavedObject[] = []; - const objectsMapWorkspaces: Record = {}; + const objectsMapWorkspaces: Record = {}; if (bulkGetDocs.length) { /** * Get latest status of objects diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts new file mode 100644 index 000000000000..112f31baf562 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { updateWorkspaceState } from '../../../../core/server/utils'; +import { SavedObject } from '../../../../core/public'; +import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; +import { WorkspaceIdConsumerWrapper } from './workspace_id_consumer_wrapper'; + +describe('WorkspaceIdConsumerWrapper', () => { + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const wrapperInstance = new WorkspaceIdConsumerWrapper(); + const mockedClient = savedObjectsClientMock.create(); + const workspaceEnabledMockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(workspaceEnabledMockRequest, { + requestWorkspaceId: 'foo', + }); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: workspaceEnabledMockRequest, + }); + const getSavedObject = (savedObject: Partial) => { + const payload: SavedObject = { + references: [], + id: '', + type: 'dashboard', + attributes: {}, + ...savedObject, + }; + + return payload; + }; + describe('create', () => { + beforeEach(() => { + mockedClient.create.mockClear(); + }); + it(`Should add workspaces parameters when create`, async () => { + await wrapperClient.create('dashboard', { + name: 'foo', + }); + + expect(mockedClient.create).toBeCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + workspaces: ['foo'], + }) + ); + }); + + it(`Should not use options.workspaces when there is no workspaces inside options`, async () => { + await wrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { + id: 'dashboard:foo', + overwrite: true, + workspaces: null, + } + ); + + expect(mockedClient.create.mock.calls[0][2]?.hasOwnProperty('workspaces')).toEqual(false); + }); + }); + + describe('bulkCreate', () => { + beforeEach(() => { + mockedClient.bulkCreate.mockClear(); + }); + it(`Should add workspaces parameters when bulk create`, async () => { + await wrapperClient.bulkCreate([ + getSavedObject({ + id: 'foo', + }), + ]); + + expect(mockedClient.bulkCreate).toBeCalledWith( + [{ attributes: {}, id: 'foo', references: [], type: 'dashboard' }], + { + workspaces: ['foo'], + } + ); + }); + }); + + describe('checkConflict', () => { + beforeEach(() => { + mockedClient.checkConflicts.mockClear(); + }); + + it(`Should add workspaces parameters when checkConflict`, async () => { + await wrapperClient.checkConflicts(); + expect(mockedClient.checkConflicts).toBeCalledWith([], { + workspaces: ['foo'], + }); + }); + }); + + describe('find', () => { + beforeEach(() => { + mockedClient.find.mockClear(); + }); + + it(`Should add workspaces parameters when find`, async () => { + await wrapperClient.find({ + type: 'dashboard', + }); + expect(mockedClient.find).toBeCalledWith({ + type: 'dashboard', + workspaces: ['foo'], + }); + }); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts new file mode 100644 index 000000000000..74e8e99af71e --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getWorkspaceState } from '../../../../core/server/utils'; +import { + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsCheckConflictsObject, + OpenSearchDashboardsRequest, + SavedObjectsFindOptions, +} from '../../../../core/server'; + +type WorkspaceOptions = Pick | undefined; + +export class WorkspaceIdConsumerWrapper { + private formatWorkspaceIdParams( + request: OpenSearchDashboardsRequest, + options?: T + ): T { + const { workspaces, ...others } = options || {}; + const workspaceState = getWorkspaceState(request); + const workspaceIdParsedFromRequest = workspaceState?.requestWorkspaceId; + const workspaceIdsInUserOptions = options?.workspaces; + let finalWorkspaces: string[] = []; + if (options?.hasOwnProperty('workspaces')) { + finalWorkspaces = workspaceIdsInUserOptions || []; + } else if (workspaceIdParsedFromRequest) { + finalWorkspaces = [workspaceIdParsedFromRequest]; + } + + return { + ...(others as T), + ...(finalWorkspaces.length ? { workspaces: finalWorkspaces } : {}), + }; + } + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + return { + ...wrapperOptions.client, + create: (type: string, attributes: T, options: SavedObjectsCreateOptions = {}) => + wrapperOptions.client.create( + type, + attributes, + this.formatWorkspaceIdParams(wrapperOptions.request, options) + ), + bulkCreate: ( + objects: Array>, + options: SavedObjectsCreateOptions = {} + ) => + wrapperOptions.client.bulkCreate( + objects, + this.formatWorkspaceIdParams(wrapperOptions.request, options) + ), + checkConflicts: ( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) => + wrapperOptions.client.checkConflicts( + objects, + this.formatWorkspaceIdParams(wrapperOptions.request, options) + ), + delete: wrapperOptions.client.delete, + find: (options: SavedObjectsFindOptions) => + wrapperOptions.client.find(this.formatWorkspaceIdParams(wrapperOptions.request, options)), + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: wrapperOptions.client.update, + bulkUpdate: wrapperOptions.client.bulkUpdate, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + deleteByWorkspace: wrapperOptions.client.deleteByWorkspace, + }; + }; + + constructor() {} +} diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 30c1c91c4223..4d5d03641b5f 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -459,6 +459,10 @@ export class WorkspaceSavedObjectsClientWrapper { [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite] ), }, + // By declaring workspaces as null, + // workspaces won't be appended automatically into the options. + // or workspaces can not be found because workspace object do not have `workspaces` field. + workspaces: null, }) ).saved_objects.map((item) => item.id); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 144ad65de9fc..c86c9e9a67e1 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -21,7 +21,10 @@ import { } from './types'; import { workspace } from './saved_objects'; import { generateRandomId } from './utils'; -import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { + WORKSPACE_ID_CONSUMER_WRAPPER_ID, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, +} from '../common/constants'; const WORKSPACE_ID_SIZE = 6; @@ -41,7 +44,15 @@ export class WorkspaceClient implements IWorkspaceClientImpl { requestDetail: IRequestDetail ): SavedObjectsClientContract | undefined { return this.savedObjects?.getScopedClient(requestDetail.request, { - excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + excludedWrappers: [ + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + /** + * workspace object does not have workspaces field + * so need to bypass workspace id consumer wrapper + * for any kind of operation to saved objects client. + */ + WORKSPACE_ID_CONSUMER_WRAPPER_ID, + ], includedHiddenTypes: [WORKSPACE_TYPE], }); } @@ -50,6 +61,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { requestDetail: IRequestDetail ): SavedObjectsClientContract { return this.savedObjects?.getScopedClient(requestDetail.request, { + excludedWrappers: [WORKSPACE_ID_CONSUMER_WRAPPER_ID], includedHiddenTypes: [WORKSPACE_TYPE], }) as SavedObjectsClientContract; }