Skip to content

Commit

Permalink
share saved objects to workspace api
Browse files Browse the repository at this point in the history
Signed-off-by: Hailong Cui <ihailong@amazon.com>
  • Loading branch information
Hailong-am committed Jul 28, 2023
1 parent efbb1ce commit cc79490
Show file tree
Hide file tree
Showing 8 changed files with 270 additions and 14 deletions.
3 changes: 3 additions & 0 deletions src/core/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ export {
exportSavedObjectsToStream,
importSavedObjectsFromStream,
resolveSavedObjectsImportErrors,
SavedObjectsShareObjects,
SavedObjectsAddToWorkspacesOptions,
SavedObjectsAddToWorkspacesResponse,
} from './saved_objects';

export {
Expand Down
3 changes: 3 additions & 0 deletions src/core/server/saved_objects/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { registerImportRoute } from './import';
import { registerResolveImportErrorsRoute } from './resolve_import_errors';
import { registerMigrateRoute } from './migrate';
import { registerCopyRoute } from './copy';
import { registerShareRoute } from './share';

export function registerRoutes({
http,
Expand Down Expand Up @@ -73,6 +74,8 @@ export function registerRoutes({
registerImportRoute(router, config);
registerCopyRoute(router, config);
registerResolveImportErrorsRoute(router, config);
// TODO disable when workspace is not enabled
registerShareRoute(router);

const internalRouter = http.createRouter('/internal/saved_objects/');

Expand Down
100 changes: 100 additions & 0 deletions src/core/server/saved_objects/routes/share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { schema } from '@osd/config-schema';
import { IRouter } from '../../http';
import { exportSavedObjectsToStream } from '../export';
import { validateObjects } from './utils';
import { collectSavedObjects } from '../import/collect_saved_objects';
import { WORKSPACE_TYPE } from '../../workspaces';

const SHARE_LIMIT = 10000;

export const registerShareRoute = (router: IRouter) => {
router.post(
{
path: '/_share',
validate: {
body: schema.object({
sourceWorkspaceId: schema.maybe(schema.string()),
objects: schema.arrayOf(
schema.object({
id: schema.string(),
type: schema.string(),
})
),
targetWorkspaceIds: schema.arrayOf(schema.string()),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const savedObjectsClient = context.core.savedObjects.client;
const { sourceWorkspaceId, objects, targetWorkspaceIds } = req.body;

// need to access the registry for type validation, can't use the schema for this
const supportedTypes = context.core.savedObjects.typeRegistry
.getAllTypes()
.filter((type) => type.name !== WORKSPACE_TYPE)
.map((t) => t.name);

if (objects) {
const validationError = validateObjects(objects, supportedTypes);
if (validationError) {
return res.badRequest({
body: {
message: validationError,
},
});
}
}

const objectsListStream = await exportSavedObjectsToStream({
savedObjectsClient,
objects,
exportSizeLimit: SHARE_LIMIT,
includeReferencesDeep: true,
excludeExportDetails: true,
});

const collectSavedObjectsResult = await collectSavedObjects({
readStream: objectsListStream,
objectLimit: SHARE_LIMIT,
supportedTypes,
});

const savedObjects = collectSavedObjectsResult.collectedObjects;

if (sourceWorkspaceId) {
const invalidObjects = savedObjects.filter((obj) => {
// TODO non-public workspace
if (obj.workspaces && obj.workspaces.length > 0) {
return !obj.workspaces.includes(sourceWorkspaceId);
}
return false;
});
if (invalidObjects && invalidObjects.length > 0) {
return res.badRequest({
body: {
message: `Saved objects are not belong to current workspace: ${invalidObjects
.map((obj) => `${obj.type}:${obj.id}`)
.join(', ')}`,
},
});
}
}

const sharedObjects = savedObjects
// non-public
.filter((obj) => obj.workspaces && obj.workspaces.length > 0)
.map((obj) => ({ id: obj.id, type: obj.type }));

const response = await savedObjectsClient.addToWorkspaces(sharedObjects, targetWorkspaceIds);

return res.ok({
body: response,
});
})
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const create = (): jest.Mocked<ISavedObjectsRepository> => ({
deleteFromNamespaces: jest.fn(),
deleteByNamespace: jest.fn(),
incrementCounter: jest.fn(),
addToWorkspaces: jest.fn(),
});

export const savedObjectsRepositoryMock = { create };
96 changes: 83 additions & 13 deletions src/core/server/saved_objects/service/lib/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,51 +32,54 @@ import { omit } from 'lodash';
import type { opensearchtypes } from '@opensearch-project/opensearch';
import uuid from 'uuid';
import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { OpenSearchClient, DeleteDocumentResponse } from '../../../opensearch/';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { DeleteDocumentResponse, OpenSearchClient } from '../../../opensearch/';
import { getRootPropertiesObjects, IndexMapping } from '../../mappings';
import {
createRepositoryOpenSearchClient,
RepositoryOpenSearchClient,
} from './repository_opensearch_client';
import { getSearchDsl } from './search_dsl';
import { includedFields } from './included_fields';
import { SavedObjectsErrorHelpers, DecoratedError } from './errors';
import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version';
import { DecoratedError, SavedObjectsErrorHelpers } from './errors';
import { decodeRequestVersion, encodeHitVersion, encodeVersion } from '../../version';
import { IOpenSearchDashboardsMigrator } from '../../migrations';
import {
SavedObjectsSerializer,
SavedObjectSanitizedDoc,
SavedObjectsRawDoc,
SavedObjectsRawDocSource,
SavedObjectsSerializer,
} from '../../serialization';
import {
SavedObjectsAddToNamespacesOptions,
SavedObjectsAddToNamespacesResponse,
SavedObjectsAddToWorkspacesOptions,
SavedObjectsAddToWorkspacesResponse,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkUpdateOptions,
SavedObjectsBulkUpdateResponse,
SavedObjectsCheckConflictsObject,
SavedObjectsCheckConflictsResponse,
SavedObjectsCreateOptions,
SavedObjectsDeleteFromNamespacesOptions,
SavedObjectsDeleteFromNamespacesResponse,
SavedObjectsDeleteOptions,
SavedObjectsFindResponse,
SavedObjectsFindResult,
SavedObjectsShareObjects,
SavedObjectsUpdateOptions,
SavedObjectsUpdateResponse,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkUpdateOptions,
SavedObjectsDeleteOptions,
SavedObjectsAddToNamespacesOptions,
SavedObjectsAddToNamespacesResponse,
SavedObjectsDeleteFromNamespacesOptions,
SavedObjectsDeleteFromNamespacesResponse,
} from '../saved_objects_client';
import {
MutatingOperationRefreshSetting,
SavedObject,
SavedObjectsBaseOptions,
SavedObjectsFindOptions,
SavedObjectsMigrationVersion,
MutatingOperationRefreshSetting,
} from '../../types';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { validateConvertFilterToKueryNode } from './filter_utils';
import {
ALL_NAMESPACES_STRING,
Expand Down Expand Up @@ -1272,6 +1275,73 @@ export class SavedObjectsRepository {
}
}

async addToWorkspaces(
objects: SavedObjectsShareObjects[],
workspaces: string[],
options: SavedObjectsAddToWorkspacesOptions = {}
): Promise<SavedObjectsAddToWorkspacesResponse[]> {
objects.forEach(({ type, id }) => {
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
});

if (!workspaces.length) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'workspaces must be a non-empty array of strings'
);
}

const { version, refresh = DEFAULT_REFRESH_SETTING } = options;
// we do not need to normalize the namespace to its ID format, since it will be converted to a namespace string before being used

const savedObjectsBulkResponse = await this.bulkGet(objects);

const docs = savedObjectsBulkResponse.saved_objects.map((obj) => {
const { type, id } = obj;
const rawId = this._serializer.generateRawId(undefined, type, id);
const existingWorkspace = obj.workspaces || [];
// there should never be a case where a multi-namespace object does not have any existing namespaces
// however, it is a possibility if someone manually modifies the document in OpenSearch
const time = this._getCurrentTime();

return [
{
update: {
_id: rawId,
_index: this.getIndexForType(type),
...getExpectedVersionProperties(version),
},
},
{
doc: {
updated_at: time,
workspaces: unique(existingWorkspace.concat(workspaces)),
},
},
];
});

const bulkUpdateResponse = await this.client.bulk({
refresh,
body: docs.flat(),
_source_includes: ['workspaces'],
});

const savedObjectIdWorkspaceMap = bulkUpdateResponse.body.items.reduce((map, item) => {
return map.set(item.update?._id!, item.update?.get?._source.workspaces);
}, new Map<string, string[]>());

return objects.map((obj) => {
const rawId = this._serializer.generateRawId(undefined, obj.type, obj.id);
return {
type: obj.type,
id: obj.id,
workspaces: savedObjectIdWorkspaceMap.get(rawId),
} as SavedObjectsAddToWorkspacesResponse;
});
}

/**
* Updates multiple objects in bulk
*
Expand Down
29 changes: 29 additions & 0 deletions src/core/server/saved_objects/service/saved_objects_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ export interface SavedObjectsCheckConflictsResponse {
}>;
}

export type SavedObjectsShareObjects = Pick<SavedObject, 'type' | 'id'>;

/**
*
* @public
Expand All @@ -193,6 +195,13 @@ export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOpti
refresh?: MutatingOperationRefreshSetting;
}

export interface SavedObjectsAddToWorkspacesOptions extends SavedObjectsBaseOptions {
/** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */
version?: string;
/** The OpenSearch Refresh setting for this operation */
refresh?: MutatingOperationRefreshSetting;
}

/**
*
* @public
Expand All @@ -202,6 +211,11 @@ export interface SavedObjectsAddToNamespacesResponse {
namespaces: string[];
}

export interface SavedObjectsAddToWorkspacesResponse extends Pick<SavedObject, 'type' | 'id'> {
/** The workspaces the object exists in after this operation is complete. */
workspaces: string[];
}

/**
*
* @public
Expand Down Expand Up @@ -433,6 +447,21 @@ export class SavedObjectsClient {
return await this._repository.deleteFromNamespaces(type, id, namespaces, options);
}

/**
* Adds workspace to SavedObjects
*
* @param objects
* @param workspaces
* @param options
*/
addToWorkspaces = async (
objects: SavedObjectsShareObjects[],
workspaces: string[],
options: SavedObjectsAddToWorkspacesOptions = {}
): Promise<SavedObjectsAddToWorkspacesResponse[]> => {
return await this._repository.addToWorkspaces(objects, workspaces, options);
};

/**
* Bulk Updates multiple SavedObject at once
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Boom from '@hapi/boom';
import {
OpenSearchDashboardsRequest,
SavedObject,
SavedObjectsAddToWorkspacesOptions,
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
Expand All @@ -17,6 +18,7 @@ import {
SavedObjectsCreateOptions,
SavedObjectsDeleteOptions,
SavedObjectsFindOptions,
SavedObjectsShareObjects,
} from 'opensearch-dashboards/server';
import {
WorkspacePermissionControl,
Expand Down Expand Up @@ -167,6 +169,34 @@ export class WorkspaceSavedObjectsClientWrapper {
return await wrapperOptions.client.find<T>(options);
};

const addToWorkspacesWithPermissionControl = async (
objects: SavedObjectsShareObjects[],
targetWorkspaces: string[],
options: SavedObjectsAddToWorkspacesOptions = {}
) => {
const objectToBulkGet = await wrapperOptions.client.bulkGet(objects, options);

// target workspaces
await this.validateMultiWorkspacesPermissions(
targetWorkspaces,
wrapperOptions.request,
WorkspacePermissionMode.Write
);

// saved_objects
const permitted = await this.permissionControl.validateSavedObjects(
objectToBulkGet.saved_objects,
WorkspacePermissionMode.Write,
wrapperOptions.request
);

if (!permitted) {
throw generateWorkspacePermissionError();
}

return await wrapperOptions.client.addToWorkspaces(objects, targetWorkspaces, options);
};

return {
...wrapperOptions.client,
get: getWithWorkspacePermissionControl,
Expand All @@ -181,6 +211,7 @@ export class WorkspaceSavedObjectsClientWrapper {
delete: deleteWithWorkspacePermissionControl,
update: wrapperOptions.client.update,
bulkUpdate: wrapperOptions.client.bulkUpdate,
addToWorkspaces: addToWorkspacesWithPermissionControl,
};
};

Expand Down
Loading

0 comments on commit cc79490

Please sign in to comment.