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] Support global workspace #7096

Closed
2 changes: 2 additions & 0 deletions changelogs/fragments/7096.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- [Workspace] Support global workspace. ([#7096](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7096))
2 changes: 1 addition & 1 deletion src/core/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const WORKSPACE_PATH_PREFIX = '/w';

/**
* public workspace has parity with global tenant,
* it includes saved objects with `public` as its workspace or without any workspce info
* it includes saved objects with `public` as its workspace or without any workspace info
*/
export const PUBLIC_WORKSPACE_ID = 'public';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ import {
import { Flyout, Relationships } from './components';
import { SavedObjectWithMetadata } from '../../types';
import { WorkspaceObject } from 'opensearch-dashboards/public';
import { PUBLIC_WORKSPACE_NAME, PUBLIC_WORKSPACE_ID } from '../../../../../core/public';
import { TableProps } from './components/table';

const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search'];
Expand Down Expand Up @@ -413,10 +412,18 @@ describe('SavedObjectsTable', () => {
});

it('should export all, accounting for the current workspace criteria', async () => {
const component = shallowRender();
const workspaceList: WorkspaceObject[] = [
{
id: 'workspace1',
name: 'foo',
},
];
workspaces.workspaceList$.next(workspaceList);

const component = shallowRender({ workspaces });

component.instance().onQueryChange({
query: Query.parse(`test workspaces:("${PUBLIC_WORKSPACE_NAME}")`),
query: Query.parse(`test workspaces:("foo")`),
});

// Ensure all promises resolve
Expand All @@ -435,7 +442,7 @@ describe('SavedObjectsTable', () => {
allowedTypes,
'test*',
true,
[PUBLIC_WORKSPACE_ID]
['workspace1']
);
expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson');
expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({
Expand Down Expand Up @@ -708,10 +715,9 @@ describe('SavedObjectsTable', () => {
expect(filters.length).toBe(2);
expect(filters[0].field).toBe('type');
expect(filters[1].field).toBe('workspaces');
expect(filters[1].options.length).toBe(3);
expect(filters[1].options.length).toBe(2);
expect(filters[1].options[0].value).toBe('foo');
expect(filters[1].options[1].value).toBe('bar');
expect(filters[1].options[2].value).toBe(PUBLIC_WORKSPACE_NAME);
});

it('workspace filter only include current workspaces when in a workspace', async () => {
Expand Down Expand Up @@ -847,7 +853,7 @@ describe('SavedObjectsTable', () => {
expect(findObjectsMock).toBeCalledWith(
http,
expect.objectContaining({
workspaces: expect.arrayContaining(['workspace1', 'workspace2', PUBLIC_WORKSPACE_ID]),
workspaces: expect.arrayContaining(['workspace1', 'workspace2']),
})
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ import {
WorkspaceAttribute,
} from 'src/core/public';
import { Subscription } from 'rxjs';
import { PUBLIC_WORKSPACE_ID, PUBLIC_WORKSPACE_NAME } from '../../../../../core/public';
import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public';
import { IndexPatternsContract } from '../../../../data/public';
import {
Expand Down Expand Up @@ -194,8 +193,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
} else {
// application home
if (!currentWorkspaceId) {
// public workspace is virtual at this moment
return availableWorkspaces?.map((ws) => ws.id).concat(PUBLIC_WORKSPACE_ID);
return availableWorkspaces?.map((ws) => ws.id);
} else {
return [currentWorkspaceId];
}
Expand All @@ -205,7 +203,6 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
private get workspaceNameIdLookup() {
const { availableWorkspaces } = this.state;
const workspaceNameIdMap = new Map<string, string>();
workspaceNameIdMap.set(PUBLIC_WORKSPACE_NAME, PUBLIC_WORKSPACE_ID);
// workspace name is unique across the system
availableWorkspaces?.forEach((workspace) => {
workspaceNameIdMap.set(workspace.name, workspace.id);
Expand Down Expand Up @@ -956,8 +953,6 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
// Add workspace filter
if (workspaceEnabled && availableWorkspaces?.length) {
const wsCounts = savedObjectCounts.workspaces || {};
const publicWorkspaceExists =
availableWorkspaces.findIndex((workspace) => workspace.id === PUBLIC_WORKSPACE_ID) > -1;
const wsFilterOptions = availableWorkspaces
.filter((ws) => {
return this.workspaceIdQuery?.includes(ws.id);
Expand All @@ -970,15 +965,6 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
};
});

// add public workspace option only if we don't have it as real workspace
if (!currentWorkspaceId && !publicWorkspaceExists) {
wsFilterOptions.push({
name: PUBLIC_WORKSPACE_NAME,
value: PUBLIC_WORKSPACE_NAME,
view: `${PUBLIC_WORKSPACE_NAME} (${wsCounts[PUBLIC_WORKSPACE_ID] || 0})`,
});
}

filters.push({
type: 'field_value_selection',
field: 'workspaces',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@

import { schema } from '@osd/config-schema';
import { IRouter, SavedObjectsFindOptions } from 'src/core/server';
import { PUBLIC_WORKSPACE_ID } from '../../../../core/server';
import { findAll } from '../lib';

export const registerScrollForCountRoute = (router: IRouter) => {
Expand Down Expand Up @@ -92,7 +91,7 @@ export const registerScrollForCountRoute = (router: IRouter) => {
});
}
if (requestHasWorkspaces) {
const resultWorkspaces = result.workspaces || [PUBLIC_WORKSPACE_ID];
const resultWorkspaces = result.workspaces || [];
resultWorkspaces.forEach((ws) => {
counts.workspaces[ws] = counts.workspaces[ws] || 0;
counts.workspaces[ws]++;
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/workspace/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class WorkspacePlugin implements Plugin<WorkspacePluginSetup, WorkspacePl
const globalConfig = await this.globalConfig$.pipe(first()).toPromise();
const isPermissionControlEnabled = globalConfig.savedObjects.permission.enabled === true;

this.client = new WorkspaceClient(core);
this.client = new WorkspaceClient(core, this.logger);

await this.client.setup(core);

Expand All @@ -136,7 +136,7 @@ export class WorkspacePlugin implements Plugin<WorkspacePluginSetup, WorkspacePl
core.savedObjects.addClientWrapper(
PRIORITY_FOR_WORKSPACE_ID_CONSUMER_WRAPPER,
WORKSPACE_ID_CONSUMER_WRAPPER_ID,
new WorkspaceIdConsumerWrapper(isPermissionControlEnabled).wrapperFactory
new WorkspaceIdConsumerWrapper().wrapperFactory
);

const maxImportExportSize = core.savedObjects.getImportExportObjectLimit();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class WorkspaceConflictSavedObjectsClientWrapper {
return {
type,
id: id as string,
fields: ['id', 'workspaces'],
fields: ['id', 'workspaces', 'permissions'],
};
})
: [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,54 +116,6 @@ describe('WorkspaceIdConsumerWrapper', () => {
});
});

it(`Should set workspacesSearchOperator to OR when search with public workspace`, async () => {
await wrapperClient.find({
type: 'dashboard',
workspaces: [PUBLIC_WORKSPACE_ID],
});
expect(mockedClient.find).toBeCalledWith({
type: 'dashboard',
workspaces: [PUBLIC_WORKSPACE_ID],
workspacesSearchOperator: 'OR',
});
});

it(`Should set workspace as pubic when workspace is not specified`, async () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
updateWorkspaceState(mockRequest, {});
const mockedWrapperClient = wrapperInstance.wrapperFactory({
client: mockedClient,
typeRegistry: requestHandlerContext.savedObjects.typeRegistry,
request: mockRequest,
});
await mockedWrapperClient.find({
type: ['dashboard', 'visualization'],
});
expect(mockedClient.find).toBeCalledWith({
type: ['dashboard', 'visualization'],
workspaces: [PUBLIC_WORKSPACE_ID],
workspacesSearchOperator: 'OR',
});
});

it(`Should remove public workspace when permission control is enabled`, async () => {
const consumer = new WorkspaceIdConsumerWrapper(true);
const client = consumer.wrapperFactory({
client: mockedClient,
typeRegistry: requestHandlerContext.savedObjects.typeRegistry,
request: workspaceEnabledMockRequest,
});
await client.find({
type: 'dashboard',
workspaces: ['bar', PUBLIC_WORKSPACE_ID],
});
expect(mockedClient.find).toBeCalledWith({
type: 'dashboard',
workspaces: ['bar'],
workspacesSearchOperator: 'OR',
});
});

it(`Should not override workspacesSearchOperator when workspacesSearchOperator is specified`, async () => {
await wrapperClient.find({
type: 'dashboard',
Expand All @@ -178,7 +130,7 @@ describe('WorkspaceIdConsumerWrapper', () => {
});

it(`Should not pass a empty workspace array`, async () => {
const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(true);
const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper();
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
updateWorkspaceState(mockRequest, {});
const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({
Expand All @@ -192,7 +144,6 @@ describe('WorkspaceIdConsumerWrapper', () => {
// empty workspace array will get deleted
expect(mockedClient.find).toBeCalledWith({
type: ['dashboard', 'visualization'],
workspacesSearchOperator: 'OR',
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
SavedObjectsCheckConflictsObject,
OpenSearchDashboardsRequest,
SavedObjectsFindOptions,
PUBLIC_WORKSPACE_ID,
WORKSPACE_TYPE,
} from '../../../../core/server';

Expand Down Expand Up @@ -79,24 +78,6 @@ export class WorkspaceIdConsumerWrapper {
if (this.isWorkspaceType(findOptions.type)) {
return wrapperOptions.client.find(findOptions);
}

// if workspace is enabled, we always find by workspace
if (!findOptions.workspaces || findOptions.workspaces.length === 0) {
findOptions.workspaces = [PUBLIC_WORKSPACE_ID];
}

// `PUBLIC_WORKSPACE_ID` includes both saved objects without any workspace and with `PUBLIC_WORKSPACE_ID` workspace
const index = findOptions.workspaces
? findOptions.workspaces.indexOf(PUBLIC_WORKSPACE_ID)
: -1;
if (!findOptions.workspacesSearchOperator && findOptions.workspaces && index !== -1) {
findOptions.workspacesSearchOperator = 'OR';
// remove this deletion logic when public workspace becomes to real
if (this.isPermissionControlEnabled) {
// remove public workspace to make sure we can pass permission control validation, more details in `WorkspaceSavedObjectsClientWrapper`
findOptions.workspaces.splice(index, 1);
}
}
if (findOptions.workspaces && findOptions.workspaces.length === 0) {
delete findOptions.workspaces;
}
Expand All @@ -112,5 +93,5 @@ export class WorkspaceIdConsumerWrapper {
};
};

constructor(private isPermissionControlEnabled?: boolean) {}
constructor() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
SavedObjectsServiceStart,
SavedObjectsClientContract,
SavedObjectsDeleteByWorkspaceOptions,
PUBLIC_WORKSPACE_ID,
} from '../../../../core/server';
import { SavedObjectsPermissionControlContract } from '../permission_control/client';
import {
Expand Down Expand Up @@ -162,8 +163,8 @@ export class WorkspaceSavedObjectsClientWrapper {
*
* 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.
* with any workspaces nor has permissions defined by itself. Such objects are considered as
* legacy object from global tenant.
*
**/
if (!savedObject.workspaces && !savedObject.permissions) {
Expand Down
63 changes: 62 additions & 1 deletion src/plugins/workspace/server/workspace_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
CoreSetup,
WorkspaceAttribute,
SavedObjectsServiceStart,
PUBLIC_WORKSPACE_ID,
PUBLIC_WORKSPACE_NAME,
Logger,
ACL,
} from '../../../core/server';
import { WORKSPACE_TYPE } from '../../../core/server';
import {
Expand All @@ -24,6 +28,7 @@
import {
WORKSPACE_ID_CONSUMER_WRAPPER_ID,
WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID,
WorkspacePermissionMode,
} from '../common/constants';

const WORKSPACE_ID_SIZE = 6;
Expand All @@ -35,9 +40,11 @@
export class WorkspaceClient implements IWorkspaceClientImpl {
private setupDep: CoreSetup;
private savedObjects?: SavedObjectsServiceStart;
private logger: Logger;

constructor(core: CoreSetup) {
constructor(core: CoreSetup, logger: Logger) {
this.setupDep = core;
this.logger = logger;
}

private getScopedClientWithoutPermission(
Expand Down Expand Up @@ -77,6 +84,47 @@
private formatError(error: Error | any): string {
return error.message || error.error || 'Error';
}
private async createGlobalWorkspace(requestDetail: IRequestDetail) {
// Permission of global workspace defaults to read only for all users except OSD admins
const globalWorkspaceACL = new ACL().addPermission(

Check warning on line 89 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L89

Added line #L89 was not covered by tests
[WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read],
{
users: ['*'],
}
);
const globalWorkspaceAttribute: Omit<WorkspaceAttribute, 'id' | 'permissions'> = {

Check warning on line 95 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L95

Added line #L95 was not covered by tests
name: i18n.translate('workspaces.public.workspace.default.name', {
defaultMessage: 'Global workspace',
}),
features: [
'use-case-observability',
'use-case-security-analytics',
'use-case-analytics',
'use-case-search',
'*',
],
// Global workspace cannot be deleted
reserved: true,
};
const savedObjectClient = this.getScopedClientWithoutPermission(requestDetail);
try {

Check warning on line 110 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L109-L110

Added lines #L109 - L110 were not covered by tests
// The global workspace is created by the OSD admin who logged in for the first time.
this.logger.info(`Creating ${PUBLIC_WORKSPACE_NAME} by login user`);
const createResult = await savedObjectClient?.create(

Check warning on line 113 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L112-L113

Added lines #L112 - L113 were not covered by tests
WORKSPACE_TYPE,
globalWorkspaceAttribute,
{
id: PUBLIC_WORKSPACE_ID,
permissions: globalWorkspaceACL.getPermissions(),
}
);
if (createResult?.id) {
this.logger.info(`Successfully created ${PUBLIC_WORKSPACE_NAME}.`);

Check warning on line 122 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L122

Added line #L122 was not covered by tests
}
} catch (e) {
this.logger.error(`Create ${PUBLIC_WORKSPACE_NAME} error: ${e?.toString() || ''}`);
}
}
public async setup(core: CoreSetup): Promise<IResponse<boolean>> {
this.setupDep.savedObjects.registerType(workspace);
return {
Expand Down Expand Up @@ -128,6 +176,19 @@
options: WorkspaceFindOptions
): ReturnType<IWorkspaceClientImpl['list']> {
try {
const { saved_objects: allSavedObjects } = await this.getScopedClientWithoutPermission(

Check warning on line 179 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L179

Added line #L179 was not covered by tests
requestDetail
)!.find<WorkspaceAttribute>({
...options,
type: WORKSPACE_TYPE,
});

const hasGlobalWorkspace = allSavedObjects.some((item) => item.id === PUBLIC_WORKSPACE_ID);

Check warning on line 186 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L186

Added line #L186 was not covered by tests
// Create global(public) workspace if public workspace id can not be found.
if (!hasGlobalWorkspace) {
await this.createGlobalWorkspace(requestDetail);

Check warning on line 189 in src/plugins/workspace/server/workspace_client.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/server/workspace_client.ts#L189

Added line #L189 was not covered by tests
}

const {
saved_objects: savedObjects,
...others
Expand Down
Loading