Skip to content

Commit

Permalink
share saved objects to workspace api (opensearch-project#67)
Browse files Browse the repository at this point in the history
* share saved objects to workspace api

Signed-off-by: Hailong Cui <ihailong@amazon.com>

* using script for bulk update

Signed-off-by: Hailong Cui <ihailong@amazon.com>

* filter out  pulbic saved objects when sharing

Signed-off-by: Hailong Cui <ihailong@amazon.com>

* refactor saved object permission error

Signed-off-by: Hailong Cui <ihailong@amazon.com>

* fix merge issue

Signed-off-by: Hailong Cui <ihailong@amazon.com>

* permission check for target workspace

Signed-off-by: Hailong Cui <ihailong@amazon.com>

* move source workspace existence validation to repository

Signed-off-by: Hailong Cui <ihailong@amazon.com>

---------

Signed-off-by: Hailong Cui <ihailong@amazon.com>
  • Loading branch information
Hailong-am authored Aug 3, 2023
1 parent 69d7e65 commit 340bfcc
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 16 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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SavedObjectsPermissionControlContract } from './client';
export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = {
setup: jest.fn(),
validate: jest.fn(),
batchValidate: jest.fn(),
addPrinciplesToObjects: jest.fn(),
removePrinciplesFromObjects: jest.fn(),
getPrinciplesOfObjects: jest.fn(),
Expand Down
10 changes: 9 additions & 1 deletion src/core/server/saved_objects/permission_control/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@ export class SavedObjectsPermissionControl {
savedObject: SavedObjectsBulkGetObject,
permissionModeOrModes: SavedObjectsPermissionModes
) {
const savedObjectsGet = await this.bulkGetSavedObjects(request, [savedObject]);
return await this.batchValidate(request, [savedObject], permissionModeOrModes);
}

public async batchValidate(
request: OpenSearchDashboardsRequest,
savedObjects: SavedObjectsBulkGetObject[],
permissionModeOrModes: SavedObjectsPermissionModes
) {
const savedObjectsGet = await this.bulkGetSavedObjects(request, savedObjects);
if (savedObjectsGet) {
return {
success: true,
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
102 changes: 102 additions & 0 deletions src/core/server/saved_objects/routes/share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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';
import { GLOBAL_WORKSPACE_ID } from '../../workspaces/constants';

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;

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

if (nonPublicSharedObjects.length === 0) {
return res.ok({
body: savedObjects.map((savedObject) => ({
type: savedObject.type,
id: savedObject.id,
workspaces: savedObject.workspaces,
})),
});
}

const response = await savedObjectsClient.addToWorkspaces(
nonPublicSharedObjects,
targetWorkspaceIds,
{
workspaces: sourceWorkspaceId ? [sourceWorkspaceId] : undefined,
}
);
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 };
138 changes: 124 additions & 14 deletions src/core/server/saved_objects/service/lib/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,62 +28,66 @@
* under the License.
*/

import { omit } from 'lodash';
import { omit, intersection } 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,
FIND_DEFAULT_PAGE,
FIND_DEFAULT_PER_PAGE,
SavedObjectsUtils,
} from './utils';
import { GLOBAL_WORKSPACE_ID } from '../../../workspaces/constants';

// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
Expand Down Expand Up @@ -1272,6 +1276,112 @@ export class SavedObjectsRepository {
}
}

async addToWorkspaces(
savedObjects: SavedObjectsShareObjects[],
workspaces: string[],
options: SavedObjectsAddToWorkspacesOptions = {}
): Promise<SavedObjectsAddToWorkspacesResponse[]> {
if (!savedObjects.length) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'shared savedObjects must not be an empty array'
);
}

// saved objects must exist in specified workspace
if (options.workspaces) {
const invalidObjects = savedObjects.filter((obj) => {
if (
obj.workspaces &&
obj.workspaces.length > 0 &&
!obj.workspaces.includes(GLOBAL_WORKSPACE_ID)
) {
return intersection(obj.workspaces, options.workspaces).length === 0;
}
return false;
});
if (invalidObjects && invalidObjects.length > 0) {
const [savedObj] = invalidObjects;
throw SavedObjectsErrorHelpers.createConflictError(savedObj.type, savedObj.id);
}
}

savedObjects.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 { refresh = DEFAULT_REFRESH_SETTING } = options;
const savedObjectsBulkResponse = await this.bulkGet(savedObjects);

const docs = savedObjectsBulkResponse.saved_objects.map((obj) => {
const { type, id } = obj;
const rawId = this._serializer.generateRawId(undefined, type, id);
const time = this._getCurrentTime();

return [
{
update: {
_id: rawId,
_index: this.getIndexForType(type),
},
},
{
script: {
source: `
if (params.workspaces != null && ctx._source.workspaces != null && !ctx._source.workspaces?.contains(params.globalWorkspaceId)) {
ctx._source.workspaces.addAll(params.workspaces);
HashSet workspacesSet = new HashSet(ctx._source.workspaces);
ctx._source.workspaces = new ArrayList(workspacesSet);
}
ctx._source.updated_at = params.time;
`,
lang: 'painless',
params: {
time,
workspaces,
globalWorkspaceId: GLOBAL_WORKSPACE_ID,
},
},
},
];
});

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

if (bulkUpdateResponse.body.errors) {
const failures = bulkUpdateResponse.body.items
.map((item) => item.update?.error?.reason)
.join(',');
throw SavedObjectsErrorHelpers.createBadRequestError(
'Add to workspace failed with: ' + failures
);
}

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

return savedObjects.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
Loading

0 comments on commit 340bfcc

Please sign in to comment.