Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Workspace][Data Source] Support data source assignment in workspace #7101

Merged
merged 17 commits into from
Jul 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/7101.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Support data source assignment in workspace. ([#7101](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7101))
30 changes: 30 additions & 0 deletions src/core/server/saved_objects/service/lib/repository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,8 @@ describe('SavedObjectsRepository', () => {
},
};

const workspaces = ['workspace1', 'workspace2'];

const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({
items: objects.map(({ type, id }) => ({
update: {
Expand Down Expand Up @@ -1583,6 +1585,20 @@ describe('SavedObjectsRepository', () => {
});
});

it(`accepts workspaces property when providing workspaces info`, async () => {
const objects = [obj1, obj2].map((obj) => ({ ...obj, workspaces }));
await bulkUpdateSuccess(objects);
const doc = {
doc: expect.objectContaining({ workspaces }),
};
const body = [expect.any(Object), doc, expect.any(Object), doc];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
client.bulk.mockClear();
});

describe('errors', () => {
const obj = {
type: 'dashboard',
Expand Down Expand Up @@ -3950,6 +3966,8 @@ describe('SavedObjectsRepository', () => {
},
};

const workspaces = ['workspace1', 'workspace2'];

const updateSuccess = async (type, id, attributes, options, includeOriginId) => {
if (registry.isMultiNamespace(type)) {
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace);
Expand Down Expand Up @@ -4137,6 +4155,18 @@ describe('SavedObjectsRepository', () => {
expect.anything()
);
});

it(`accepts workspaces when providing permissions info`, async () => {
await updateSuccess(type, id, attributes, { workspaces });
const expected = expect.objectContaining({ workspaces });
const body = {
doc: expected,
};
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
});
});

describe('errors', () => {
Expand Down
32 changes: 27 additions & 5 deletions src/core/server/saved_objects/service/lib/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,13 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}

const { version, references, refresh = DEFAULT_REFRESH_SETTING, permissions } = options;
const {
version,
references,
refresh = DEFAULT_REFRESH_SETTING,
permissions,
workspaces,
} = options;
const namespace = normalizeNamespace(options.namespace);

let preflightResult: SavedObjectsRawDoc | undefined;
Expand All @@ -1104,6 +1110,7 @@ export class SavedObjectsRepository {
updated_at: time,
...(Array.isArray(references) && { references }),
...(permissions && { permissions }),
...(workspaces && { workspaces }),
};

const { body, statusCode } = await this.client.update<SavedObjectsRawDocSource>(
Expand Down Expand Up @@ -1142,6 +1149,7 @@ export class SavedObjectsRepository {
namespaces,
...(originId && { originId }),
...(permissions && { permissions }),
...(workspaces && { workspaces }),
references,
attributes,
};
Expand Down Expand Up @@ -1342,7 +1350,14 @@ export class SavedObjectsRepository {
};
}

const { attributes, references, version, namespace: objectNamespace, permissions } = object;
const {
attributes,
references,
version,
namespace: objectNamespace,
permissions,
workspaces,
} = object;

if (objectNamespace === ALL_NAMESPACES_STRING) {
return {
Expand All @@ -1364,6 +1379,7 @@ export class SavedObjectsRepository {
updated_at: time,
...(Array.isArray(references) && { references }),
...(permissions && { permissions }),
...(workspaces && { workspaces }),
};

const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type);
Expand Down Expand Up @@ -1515,8 +1531,13 @@ export class SavedObjectsRepository {
response
)[0] as any;

// eslint-disable-next-line @typescript-eslint/naming-convention
const { [type]: attributes, references, updated_at, permissions } = documentToSave;
const {
[type]: attributes,
references,
updated_at: updatedAt,
permissions,
workspaces,
} = documentToSave;
if (error) {
return {
id,
Expand All @@ -1531,11 +1552,12 @@ export class SavedObjectsRepository {
type,
...(namespaces && { namespaces }),
...(originId && { originId }),
updated_at,
updated_at: updatedAt,
version: encodeVersion(seqNo, primaryTerm),
attributes,
references,
...(permissions && { permissions }),
...(workspaces && { workspaces }),
};
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const create = () =>
update: jest.fn(),
addToNamespaces: jest.fn(),
deleteFromNamespaces: jest.fn(),
addToWorkspaces: jest.fn(),
deleteFromWorkspaces: jest.fn(),
} as unknown) as jest.Mocked<SavedObjectsClientContract>);

export const savedObjectsClientMock = { create };
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,87 @@ test(`#deleteByWorkspace`, async () => {
expect(mockRepository.deleteByWorkspace).toHaveBeenCalledWith(workspace, options);
expect(result).toBe(returnValue);
});

test(`#deleteFromWorkspaces Should use update if there is existing workspaces`, async () => {
const returnValue = Symbol();
const create = jest.fn();
const mockRepository = {
get: jest.fn().mockResolvedValue({
workspaces: ['id1', 'id2'],
}),
update: jest.fn().mockResolvedValue(returnValue),
create,
};
const client = new SavedObjectsClient(mockRepository);

const type = Symbol();
const id = Symbol();
await client.deleteFromWorkspaces(type, id, ['id2']);
expect(mockRepository.get).toHaveBeenCalledWith(type, id, {});
expect(mockRepository.update).toHaveBeenCalledWith(type, id, undefined, {
version: undefined,
workspaces: ['id1'],
});
});

test(`#deleteFromWorkspaces Should use overwrite create if there is no existing workspaces`, async () => {
const returnValue = Symbol();
const create = jest.fn();
const mockRepository = {
get: jest.fn().mockResolvedValue({
workspaces: [],
}),
update: jest.fn().mockResolvedValue(returnValue),
create,
};
const client = new SavedObjectsClient(mockRepository);

const type = Symbol();
const id = Symbol();
await client.deleteFromWorkspaces(type, id, ['id1']);
expect(mockRepository.get).toHaveBeenCalledWith(type, id, {});
expect(mockRepository.create).toHaveBeenCalledWith(
type,
{},
{ id, overwrite: true, permissions: undefined, version: undefined }
);
});

test(`#deleteFromWorkspaces should throw error if no workspaces passed`, () => {
const mockRepository = {};
const client = new SavedObjectsClient(mockRepository);
const type = Symbol();
const id = Symbol();
const workspaces = [];
expect(() => client.deleteFromWorkspaces(type, id, workspaces)).rejects.toThrowError();
});

test(`#addToWorkspaces`, async () => {
const returnValue = Symbol();
const mockRepository = {
get: jest.fn().mockResolvedValue(returnValue),
update: jest.fn().mockResolvedValue(returnValue),
};
const client = new SavedObjectsClient(mockRepository);

const type = Symbol();
const id = Symbol();
const workspaces = Symbol();
const result = await client.addToWorkspaces(type, id, workspaces);

expect(mockRepository.get).toHaveBeenCalledWith(type, id, {});
expect(mockRepository.update).toHaveBeenCalledWith(type, id, undefined, {
workspaces: [workspaces],
});

expect(result).toBe(returnValue);
});

test(`#addToWorkspaces should throw error if no workspaces passed`, () => {
const mockRepository = {};
const client = new SavedObjectsClient(mockRepository);
const type = Symbol();
const id = Symbol();
const workspaces = [];
expect(() => client.addToWorkspaces(type, id, workspaces)).rejects.toThrowError();
});
65 changes: 64 additions & 1 deletion src/core/server/saved_objects/service/saved_objects_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface SavedObjectsBulkCreateObject<T = unknown> {
* @public
*/
export interface SavedObjectsBulkUpdateObject<T = unknown>
extends Pick<SavedObjectsUpdateOptions, 'version' | 'references' | 'permissions'> {
extends Pick<SavedObjectsUpdateOptions, 'version' | 'references' | 'permissions' | 'workspaces'> {
/** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */
id: string;
/** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */
Expand Down Expand Up @@ -189,6 +189,7 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions {
refresh?: MutatingOperationRefreshSetting;
/** permission control describe by ACL object */
permissions?: Permissions;
workspaces?: string[];
}

/**
Expand Down Expand Up @@ -463,6 +464,68 @@ export class SavedObjectsClient {
return await this._repository.deleteByWorkspace(workspace, options);
};

/**
* Remove a saved object from workspaces
* @param type
* @param id
* @param workspaces
*/
deleteFromWorkspaces = async <T = unknown>(type: string, id: string, workspaces: string[]) => {
if (!workspaces || workspaces.length === 0) {
throw new TypeError(`Workspaces is required.`);
}
const object = await this.get<T>(type, id);
const existingWorkspaces = object.workspaces ?? [];
const newWorkspaces = existingWorkspaces.filter((item) => {
return workspaces.indexOf(item) === -1;
});
if (newWorkspaces.length > 0) {
return await this.update<T>(type, id, object.attributes, {
workspaces: newWorkspaces,
version: object.version,
});
} else {
// If there is no workspaces assigned, will create object with overwrite to delete workspace property.
return await this.create(
type,
{
...object.attributes,
},
{
id,
permissions: object.permissions,
overwrite: true,
version: object.version,
}
);
}
};

/**
* Add a saved object to workspaces
* @param type
* @param id
* @param workspaces
*/
addToWorkspaces = async <T = unknown>(
type: string,
id: string,
workspaces: string[]
): Promise<any> => {
if (!workspaces || workspaces.length === 0) {
throw new TypeError(`Workspaces is required.`);
}
const object = await this.get<T>(type, id);
const existingWorkspaces = object.workspaces ?? [];
const mergedWorkspaces = existingWorkspaces.concat(workspaces);
const nonDuplicatedWorkspaces = Array.from(new Set(mergedWorkspaces));

return await this.update<T>(type, id, object.attributes, {
workspaces: nonDuplicatedWorkspaces,
version: object.version,
});
};

/**
* Bulk Updates multiple SavedObject at once
*
Expand Down
11 changes: 11 additions & 0 deletions src/plugins/workspace/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources';

export type DataSource = Pick<DataSourceAttributes, 'title'> & {
// Id defined in SavedObjectAttribute could be single or array, here only should be single string.
id: string;
};
Loading
Loading