From bff1a0611a26358734c40d881c1f3f30204f69d1 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 12 Apr 2024 11:28:38 +0800 Subject: [PATCH 1/4] replace default workspace with public workspace (#322) Signed-off-by: Hailong Cui --- src/core/public/index.ts | 7 +++- src/core/server/index.ts | 2 +- src/core/utils/constants.ts | 14 +++++--- src/core/utils/index.ts | 2 +- .../saved_objects_table.test.tsx | 6 ++-- .../objects_table/saved_objects_table.tsx | 33 ++++++++++--------- .../server/routes/scroll_count.ts | 6 ++-- .../workspace/server/workspace_client.ts | 5 ++- 8 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/core/public/index.ts b/src/core/public/index.ts index a5c06f3c96ed..0dc37587032e 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -356,6 +356,11 @@ export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; -export { WORKSPACE_TYPE, cleanWorkspaceId, DEFAULT_WORKSPACE_ID } from '../utils'; +export { + WORKSPACE_TYPE, + cleanWorkspaceId, + PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, +} from '../utils'; export { debounce } from './utils'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f497bed22755..9ec85aff0376 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -356,8 +356,8 @@ export { AppCategory, WorkspaceAttribute } from '../types'; export { DEFAULT_APP_CATEGORIES, PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, WORKSPACE_TYPE, - DEFAULT_WORKSPACE_ID, } from '../utils'; export { diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 5c2a24f59b0d..c05d2b06e041 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -3,14 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; + export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; -export const PUBLIC_WORKSPACE_ID = 'public'; - /** - * deafult workspace is a virtual workspace, - * saved objects without any workspaces are consider belongs to default workspace + * public workspace has parity with global tenant, + * it includes saved objects with `public` as its workspace or without any workspce info */ -export const DEFAULT_WORKSPACE_ID = 'default'; +export const PUBLIC_WORKSPACE_ID = 'public'; + +export const PUBLIC_WORKSPACE_NAME = i18n.translate('workspaces.public.workspace.default.name', { + defaultMessage: 'Global workspace', +}); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index e2f5fd90460a..d15ee2d538aa 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -41,6 +41,6 @@ export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } fro export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, WORKSPACE_TYPE, - DEFAULT_WORKSPACE_ID, } from './constants'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 8f895419400d..fdcd16f1a068 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -64,7 +64,7 @@ import { import { Flyout, Relationships } from './components'; import { SavedObjectWithMetadata } from '../../types'; import { WorkspaceObject } from 'opensearch-dashboards/public'; -import { DEFAULT_WORKSPACE_ID } from '../../../../../core/public'; +import { PUBLIC_WORKSPACE_ID } from '../../../../../core/public'; import { TableProps } from './components/table'; const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; @@ -696,7 +696,7 @@ describe('SavedObjectsTable', () => { expect(filters[1].options.length).toBe(3); expect(filters[1].options[0].value).toBe('foo'); expect(filters[1].options[1].value).toBe('bar'); - expect(filters[1].options[2].value).toBe(DEFAULT_WORKSPACE_ID); + expect(filters[1].options[2].value).toBe(PUBLIC_WORKSPACE_ID); }); it('show workspace filter when workspace turn on and enter a workspace', async () => { @@ -832,7 +832,7 @@ describe('SavedObjectsTable', () => { expect(findObjectsMock).toBeCalledWith( http, expect.objectContaining({ - workspaces: expect.arrayContaining(['workspace1', 'default']), + workspaces: expect.arrayContaining(['workspace1', PUBLIC_WORKSPACE_ID]), workspacesSearchOperator: expect.stringMatching('OR'), }) ); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 1b1de928be03..0a078d5ee044 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -69,7 +69,7 @@ import { WorkspaceAttribute, } from 'src/core/public'; import { Subscription } from 'rxjs'; -import { DEFAULT_WORKSPACE_ID } from '../../../../../core/public'; +import { PUBLIC_WORKSPACE_ID, PUBLIC_WORKSPACE_NAME } from '../../../../../core/public'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -193,21 +193,21 @@ export class SavedObjectsTable extends Component ws.id).concat(DEFAULT_WORKSPACE_ID); + return availableWorkspaces?.map((ws) => ws.id).concat(PUBLIC_WORKSPACE_ID); } else { return [currentWorkspaceId]; } } } - private get wsNameIdLookup() { + private get workspaceNameIdLookup() { const { availableWorkspaces } = this.state; const workspaceNameIdMap = new Map(); - workspaceNameIdMap.set(DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_ID); - // Assumption: workspace name is unique across the system - availableWorkspaces?.reduce((map, ws) => { - return map.set(ws.name, ws.id); - }, workspaceNameIdMap); + workspaceNameIdMap.set(PUBLIC_WORKSPACE_NAME, PUBLIC_WORKSPACE_ID); + // workspace name is unique across the system + availableWorkspaces?.forEach((workspace) => { + workspaceNameIdMap.set(workspace.name, workspace.id); + }); return workspaceNameIdMap; } @@ -258,7 +258,7 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || '') + .map((wsName) => this.workspaceNameIdLookup?.get(wsName) || '') .filter((wsId) => !!wsId); } @@ -351,13 +351,13 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || '' + (wsName) => this.workspaceNameIdLookup?.get(wsName) || '' ); findOptions.workspaces = workspaceIds; } if (findOptions.workspaces) { - if (findOptions.workspaces.indexOf(DEFAULT_WORKSPACE_ID) !== -1) { + if (findOptions.workspaces.indexOf(PUBLIC_WORKSPACE_ID) !== -1) { // search both saved objects with workspace and without workspace findOptions.workspacesSearchOperator = 'OR'; } @@ -961,6 +961,8 @@ export class SavedObjectsTable extends Component workspace.id === PUBLIC_WORKSPACE_ID) > -1; const wsFilterOptions = availableWorkspaces .filter((ws) => { return this.workspaceIdQuery?.includes(ws.id); @@ -973,11 +975,12 @@ export class SavedObjectsTable extends Component { @@ -70,7 +70,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { if (requestHasWorkspaces) { counts.workspaces = {}; findOptions.workspaces = req.body.workspaces; - if (findOptions.workspaces.indexOf(DEFAULT_WORKSPACE_ID) !== -1) { + if (findOptions.workspaces.indexOf(PUBLIC_WORKSPACE_ID) !== -1) { // search both saved objects with workspace and without workspace findOptions.workspacesSearchOperator = 'OR'; } @@ -96,7 +96,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { }); } if (requestHasWorkspaces) { - const resultWorkspaces = result.workspaces || [DEFAULT_WORKSPACE_ID]; + const resultWorkspaces = result.workspaces || [PUBLIC_WORKSPACE_ID]; resultWorkspaces.forEach((ws) => { counts.workspaces[ws] = counts.workspaces[ws] || 0; counts.workspaces[ws]++; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 79df5f5d4558..9462bfdd9242 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -14,6 +14,7 @@ import { import { DEFAULT_APP_CATEGORIES, PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, WORKSPACE_TYPE, Logger, } from '../../../core/server'; @@ -108,9 +109,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } private async setupPublicWorkspace(savedObjectClient?: SavedObjectsClientContract) { return this.checkAndCreateWorkspace(savedObjectClient, PUBLIC_WORKSPACE_ID, { - name: i18n.translate('workspaces.public.workspace.default.name', { - defaultMessage: 'Global workspace', - }), + name: PUBLIC_WORKSPACE_NAME, features: ['*', `!@${DEFAULT_APP_CATEGORIES.management.id}`], reserved: true, }); From f792a32433d159c484872a0fc36c7b599e4dff9a Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 12 Apr 2024 12:51:28 +0800 Subject: [PATCH 2/4] [Workspace] Add duplicate saved objects API (#6288) (#326) * Add copy saved objects API * Modify change log * Add documents for all saved objects APIs * Revert the yml file change * Move the duplicate api to workspace plugin * Modify change log * Modify api doc * Check target workspace exists or not * Remove unused import * Fix test failure * Modify change log * Modify workspace doc * Add more unit tests * Some minor change * Fix test failure * Modify test description * Optimize test description * Modify test case * Minor change --------- Signed-off-by: gaobinlong --- src/core/server/saved_objects/routes/index.ts | 2 - src/plugins/saved_objects/README.md | 594 ++++++++++++++++++ src/plugins/workspace/README.md | 319 ++++++++++ .../integration_tests/duplicate.test.ts} | 112 +++- .../server/integration_tests/routes.test.ts | 146 +++++ src/plugins/workspace/server/plugin.ts | 2 + .../workspace/server/routes/duplicate.ts} | 46 +- src/plugins/workspace/server/routes/index.ts | 8 +- 8 files changed, 1191 insertions(+), 38 deletions(-) create mode 100644 src/plugins/workspace/README.md rename src/{core/server/saved_objects/routes/integration_tests/copy.test.ts => plugins/workspace/server/integration_tests/duplicate.test.ts} (69%) rename src/{core/server/saved_objects/routes/copy.ts => plugins/workspace/server/routes/duplicate.ts} (62%) diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 6c70276d7387..7149474e446c 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -45,7 +45,6 @@ import { registerExportRoute } from './export'; import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; -import { registerCopyRoute } from './copy'; export function registerRoutes({ http, @@ -72,7 +71,6 @@ export function registerRoutes({ registerExportRoute(router, config); registerImportRoute(router, config); registerResolveImportErrorsRoute(router, config); - registerCopyRoute(router, config); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 2f7d98dbb36b..f323b4a94609 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -175,3 +175,597 @@ The migraton version will be saved as a `migrationVersion` attribute in the save ``` For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). + +## Server APIs + +### Get saved objects API + +Retrieve a single saved object by its ID. + +* Path and HTTP methods + +```json +GET :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | YES | The ID of the saved object. | + +* Example request + +```json +GET api/saved_objects/index-pattern/619cc200-ecd0-11ee-95b1-e7363f9e289d +``` + +* Example response + +```json +{ + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "type": "index-pattern", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + } +} +``` + +### Bulk get saved objects API + +Retrieve mutiple saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_bulk_get +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | +| `fields` | Array | NO | The fields of the saved obejct need to be returned in the response. | + +* Example request + +```json +POST api/saved_objects/_bulk_get +[ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + }, + { + "type": "config", + "id": "3.0.0" + } +] +``` + +* Example response + +```json +{ + "saved_objects": [ + { + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "type": "index-pattern", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + } + }, + { + "id": "3.0.0", + "type": "config", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-19T06:11:41.608Z", + "version": "WzAsMV0=", + "attributes": { + "buildNum": 9007199254740991 + }, + "references": [ + + ], + "migrationVersion": { + "config": "7.9.0" + } + } + ] +} +``` + +### Find saved objects API + +Retrieve a paginated set of saved objects by mulitple conditions. + +* Path and HTTP methods + +```json +GET :/api/saved_objects/_find +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `per_page` | Number | NO | The number of saved objects to return in each page. | +| `page` | Number | NO | The page of saved objects to return. | +| `search` | String | NO | A `simple_query_string` query DSL that used to filter the saved objects. | +| `default_search_operator` | String | NO | The default operator to use for the `simple_query_string` query. | +| `search_fields` | Array | NO | The fields to perform the `simple_query_string` parsed query against. | +| `fields` | Array | NO | The fields of the saved obejct need to be returned in the response. | +| `sort_field` | String | NO | The field used for sorting the response. | +| `has_reference` | Object | NO | Filters to objects that have a relationship with the type and ID combination. | +| `filter` | String | NO | The query string used to filter the attribute of the saved object. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +GET api/saved_objects/_find?type=index-pattern&search_fields=title +``` + +* Example response + +```json +{ + "page": 1, + "per_page": 20, + "total": 2, + "saved_objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "namespaces": [ + "default" + ], + "score": 0 + }, + { + "type": "index-pattern", + "id": "2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-28T07:10:13.513Z", + "version": "WzEwLDJd", + "workspaces": [ + "9gt4lB" + ], + "namespaces": [ + "default" + ], + "score": 0 + } + ] +} +``` + +### Create saved objects API + +Create saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO |The ID of the saved object. | + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/index-pattern/test-pattern +{ + "attributes": { + "title": "test-pattern-*" + } +} +``` + +* Example response + +```json +{ + "type": "index-pattern", + "id": "test-pattern", + "attributes": { + "title": "test-pattern-*" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-29T05:55:09.270Z", + "version": "WzExLDJd", + "namespaces": [ + "default" + ] +} +``` + +### Bulk create saved objects API + +Bulk create saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_bulk_create +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | NO |The ID of the saved object. | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | +| `version` | String | NO | The version of the saved object. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/_bulk_create +[ + { + "type": "index-pattern", + "id": "test-pattern1", + "attributes": { + "title": "test-pattern1-*" + } + } +] +``` + +* Example response + +```json +{ + "saved_objects": [ + { + "type": "index-pattern", + "id": "test-pattern1", + "attributes": { + "title": "test-pattern1-*" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-29T06:01:59.453Z", + "version": "WzEyLDJd", + "namespaces": [ + "default" + ] + } + ] +} +``` +### Upate saved objects API + +Update saved objects. + +* Path and HTTP methods + +```json +PUT :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO |The ID of the saved object. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | + +* Example request + +```json +PUT api/saved_objects/index-pattern/test-pattern +{ + "attributes": { + "title": "test-pattern-update-*" + } +} +``` + +* Example response + +```json +{ + "id": "test-pattern", + "type": "index-pattern", + "updated_at": "2024-03-29T06:04:32.743Z", + "version": "WzEzLDJd", + "namespaces": [ + "default" + ], + "attributes": { + "title": "test-pattern-update-*" + } +} +``` +### Delete saved objects API + +Delete saved objects. + +* Path and HTTP methods + +```json +DELETE :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO | The ID of the saved object. | + +* Example request + +```json +DELETE api/saved_objects/index-pattern/test-pattern +``` + +* Example response + +```json +{} +``` +### Export saved object API + +Export saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_export +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String|Array | NO | The types of the saved object to be included in the export. | +| `objects` | Array | NO | A list of saved objects to export. | +| `includeReferencesDeep` | Boolean | NO | Includes all of the referenced objects in the export. | +| `excludeExportDetails` | Boolean | NO | Exclude the export summary in the export. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/_export +{ + "type": "index-pattern" +} +``` + +* Example response + +```json +{"attributes":{"fields":"[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]","title":"test*"},"id":"2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-28T07:10:13.513Z","version":"WzEwLDJd","workspaces":["9gt4lB"]} +{"attributes":{"fields":"[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]","title":"test*"},"id":"619cc200-ecd0-11ee-95b1-e7363f9e289d","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-28T06:57:03.008Z","version":"WzksMl0="} +{"attributes":{"title":"test-pattern1-*"},"id":"test-pattern1","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-29T06:01:59.453Z","version":"WzEyLDJd"} +{"exportedCount":3,"missingRefCount":0,"missingReferences":[]} +``` + +### Import saved object API + +Import saved objects from the file generated by the export API. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_import +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | +| `overwrite` | Boolean | NO | Overwrites the saved objects when they already exist. | +| `dataSourceId` | String | NO | The ID of the data source. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Request body + +The request body must include a multipart/form-data. + +* Example request + +```json +POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson +``` + +* Example response + +```json +{ + "successCount": 3, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f0b08067-d6ab-4153-ba7d-0304506430d6" + }, + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "ffd3719c-2314-4022-befc-7d3007225952" + }, + { + "type": "index-pattern", + "id": "test-pattern1", + "meta": { + "title": "test-pattern1-*", + "icon": "indexPatternApp" + }, + "destinationId": "e87e7f2d-8498-4e44-8d25-f7d41f3b3844" + } + ] +} +``` + +### Resolve import saved objects errors API + +Resolve the errors if the import API returns errors, this API can be used to retry importing some saved obejcts, overwrite specific saved objects, or change the references to different saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_resolve_import_errors +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | +| `dataSourceId` | String | NO | The ID of the data source. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Request body + +The request body must include a multipart/form-data. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `file` | ndjson file | YES | The same file given to the import API. | +| `retries` | Array | YES | The retry operations. | + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES |The ID of the saved object. | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | +| `destinationId` | String | NO | The destination ID that the imported object should have, if different from the current ID. | +| `replaceReferences` | Array | NO | A list of `type`, `from`, and `to` to be used to change the saved object's references. | +| `ignoreMissingReferences` | Boolean | NO | If `true`, ignores missing reference errors, defaults to `false`. | + +* Example request + +```json +POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson --form retries='[{"type":"index-pattern","id":"my-pattern","overwrite":true}]' + +``` + +* Example response + +```json +{ + "successCount": 0, + "success": true +} +``` diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md new file mode 100644 index 000000000000..7e3fff562d82 --- /dev/null +++ b/src/plugins/workspace/README.md @@ -0,0 +1,319 @@ +# Workspace + +## Server APIs + +### List workspaces API + +List workspaces. + +* Path and HTTP methods + +```json +POST :/api/workspaces/_list +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `search` | String | NO | A `simple_query_string` query DSL used to search the workspaces. | +| `searchFields` | Array | NO | The fields to perform the `simple_query_string` parsed query against. | +| `sortField` | String | NO | The fields used for sorting the response. | +| `sortOrder` | String | NO | The order used for sorting the response. | +| `perPage` | String | NO | The number of workspaces to return in each page. | +| `page` | String | NO | The page of workspaces to return. | +| `permissionModes` | Array | NO | The permission mode list. | + +* Example request + +```json +POST api/workspaces/_list +``` + +* Example response + +```json +{ + "success": true, + "result": { + "page": 1, + "per_page": 20, + "total": 3, + "workspaces": [ + { + "name": "test1", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query", + "dev_tools" + ], + "id": "hWNZls" + }, + { + "name": "test2", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query" + ], + "id": "SnkOPt" + }, + { + "name": "Global workspace", + "features": [ + "*", + "!@management" + ], + "reserved": true, + "id": "public" + } + ] + } +} +``` + + +### Get workspace API + +Retrieve a single workspace. + +* Path and HTTP methods + +```json +GET :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + +* Example request + +```json +GET api/workspaces/SnkOPt +``` + +* Example response + +```json +{ + "success": true, + "result": { + "name": "test2", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query" + ], + "id": "SnkOPt" + } +} +``` + +### Create workspace API + +Create a workspace. + +* Path and HTTP methods + +```json +POST :/api/workspaces +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the workspace. | +| `permissions` | Object | NO | The permission info of the workspace. | + + +* Example request + +```json +POST api/workspaces +{ + "attributes": { + "name": "test4", + "description": "test4" + } +} +``` + +* Example response + +```json +{ + "success": true, + "result": { + "id": "eHVoCJ" + } +} +``` + +### Update workspace API + +Update the attributes and permissions of a workspace. + +* Path and HTTP methods + +```json +PUT :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the workspace. | +| `permissions` | Object | NO | The permission info of the workspace. | + + +* Example request + +```json +PUT api/workspaces/eHVoCJ +{ + "attributes": { + "name": "test4", + "description": "test update" + } +} +``` + +* Example response + +```json +{ + "success": true, + "result": true +} +``` + +### Delete workspace API + +Delete a workspace. + +* Path and HTTP methods + +```json +DELETE :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + + +* Example request + +```json +DELETE api/workspaces/eHVoCJ +``` + +* Example response + +```json +{ + "success": true, + "result": true +} +``` + +### Duplicate saved objects API + +Duplicate saved objects among workspaces. + +* Path and HTTP methods + +```json +POST :/api/workspaces/_duplicate_saved_objects +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objects` | Array | YES | A list of saved objects to copy. | +| `targetWorkspace` | String | YES | The ID of the workspace to copy to. | +| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . Defaults to `true`.| + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | + +* Example request + +```json +POST api/workspaces/_duplicate_saved_objects +{ + "objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + } + ], + "targetWorkspace": "9gt4lB" +} +``` + +* Example response + +```json +{ + "successCount": 1, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f4b724fd-9647-4bbf-bf59-610b43a62c75" + } + ] +} +``` + diff --git a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts similarity index 69% rename from src/core/server/saved_objects/routes/integration_tests/copy.test.ts rename to src/plugins/workspace/server/integration_tests/duplicate.test.ts index e8a9d83b30ea..e994586c631c 100644 --- a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -3,30 +3,57 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as exportMock from '../../export'; -import { createListStream } from '../../../utils/streams'; -import { mockUuidv4 } from '../../import/__mocks__'; +import * as exportMock from '../../../../core/server'; import supertest from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; import { UnwrapPromise } from '@osd/utility-types'; -import { registerCopyRoute } from '../copy'; -import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { SavedObjectConfig } from '../../saved_objects_config'; -import { setupServer, createExportableType } from '../test_utils'; -import { SavedObjectsErrorHelpers } from '../..'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/server/mocks'; +import { setupServer } from '../../../../core/server/test_utils'; +import { registerDuplicateRoute } from '../routes/duplicate'; +import { createListStream } from '../../../../core/server/utils/streams'; +import Boom from '@hapi/boom'; -jest.mock('../../export', () => ({ +jest.mock('../../../../core/server/saved_objects/export', () => ({ exportSavedObjectsToStream: jest.fn(), })); type SetupServerReturn = UnwrapPromise>; -const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; -const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; -const URL = '/internal/saved_objects/_copy'; +const URL = '/api/workspaces/_duplicate_saved_objects'; const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; +const logger = loggingSystemMock.create(); +const clientMock = { + init: jest.fn(), + enterWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + getCurrentWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + stop: jest.fn(), + setup: jest.fn(), + destroy: jest.fn(), + setSavedObjects: jest.fn(), +}; -describe(`POST ${URL}`, () => { +export const createExportableType = (name: string): exportMock.SavedObjectsType => { + return { + name, + hidden: false, + namespaceType: 'single', + mappings: { + properties: {}, + }, + management: { + importableAndExportable: true, + }, + }; +}; + +describe(`duplicate saved objects among workspaces`, () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; let handlerContext: SetupServerReturn['handlerContext']; @@ -59,8 +86,6 @@ describe(`POST ${URL}`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -75,8 +100,9 @@ describe(`POST ${URL}`, () => { savedObjectsClient.find.mockResolvedValue(emptyResponse); savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); - const router = httpSetup.createRouter('/internal/saved_objects/'); - registerCopyRoute(router, config); + const router = httpSetup.createRouter(''); + + registerDuplicateRoute(router, logger.get(), clientMock, 10000); await server.start(); }); @@ -85,8 +111,16 @@ describe(`POST ${URL}`, () => { await server.stop(); }); - it('formats successful response', async () => { - exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); + it('duplicate failed if the requested saved objects are not valid', async () => { + const savedObjects = [mockIndexPattern, mockDashboard]; + clientMock.get.mockResolvedValueOnce({ success: true }); + exportSavedObjectsToStream.mockImplementation(() => { + const err = Boom.badRequest(); + err.output.payload.attributes = { + objects: savedObjects, + }; + throw err; + }); const result = await supertest(httpSetup.server.listener) .post(URL) @@ -104,9 +138,9 @@ describe(`POST ${URL}`, () => { includeReferencesDeep: true, targetWorkspace: 'test_workspace', }) - .expect(200); + .expect(400); - expect(result.body).toEqual({ success: true, successCount: 0 }); + expect(result.body.error).toEqual('Bad Request'); expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); @@ -141,7 +175,33 @@ describe(`POST ${URL}`, () => { ); }); - it('copy unsupported objects', async () => { + it('target workspace does not exist', async () => { + clientMock.get.mockResolvedValueOnce({ success: false }); + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'non-existen-workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Get target workspace non-existen-workspace error: undefined"` + ); + }); + + it('duplicate unsupported objects', async () => { + clientMock.get.mockResolvedValueOnce({ success: true }); const result = await supertest(httpSetup.server.listener) .post(URL) .send({ @@ -157,13 +217,14 @@ describe(`POST ${URL}`, () => { .expect(400); expect(result.body.message).toMatchInlineSnapshot( - `"Trying to copy object(s) with unsupported types: unknown:my-pattern"` + `"Trying to duplicate object(s) with unsupported types: unknown:my-pattern"` ); }); - it('copy index pattern and dashboard into a workspace successfully', async () => { + it('duplicate index pattern and dashboard into a workspace successfully', async () => { const targetWorkspace = 'target_workspace_id'; const savedObjects = [mockIndexPattern, mockDashboard]; + clientMock.get.mockResolvedValueOnce({ success: true }); exportSavedObjectsToStream.mockResolvedValueOnce(createListStream(savedObjects)); savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: savedObjects.map((obj) => ({ ...obj, workspaces: [targetWorkspace] })), @@ -205,7 +266,7 @@ describe(`POST ${URL}`, () => { expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); }); - it('copy a visualization with missing references', async () => { + it('duplicate a saved object failed if its references are missing', async () => { const targetWorkspace = 'target_workspace_id'; const savedObjects = [mockVisualization]; const exportDetail = { @@ -213,6 +274,7 @@ describe(`POST ${URL}`, () => { missingRefCount: 1, missingReferences: [{ type: 'index-pattern', id: 'my-pattern' }], }; + clientMock.get.mockResolvedValueOnce({ success: true }); exportSavedObjectsToStream.mockResolvedValueOnce( createListStream(...savedObjects, exportDetail) ); diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 832c43c66399..972a43a17389 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -271,6 +271,152 @@ describe('workspace service api integration test', () => { expect(listResult.body.result.total).toEqual(2); }); }); + + describe('Duplicate saved objects APIs', () => { + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + + afterAll(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + const savedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ + WORKSPACE_TYPE, + ]); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + // this will delete reserved workspace + savedObjectsRepository.delete(WORKSPACE_TYPE, item.id) + ) + ); + }); + + it('requires objects', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({}) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.objects]: expected value of type [array] but got [undefined]"` + ); + }); + + it('requires target workspace', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.targetWorkspace]: expected value of type [string] but got [undefined]"` + ); + }); + + it('duplicate unsupported objects', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'unknown', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Trying to duplicate object(s) with unsupported types: unknown:my-pattern"` + ); + }); + + it('target workspace does not exist', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Get target workspace test_workspace error: Saved object [workspace/test_workspace] not found"` + ); + }); + + it('duplicate index pattern and dashboard into a workspace successfully', async () => { + const createWorkspaceResult: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + expect(createWorkspaceResult.body.success).toEqual(true); + expect(typeof createWorkspaceResult.body.result.id).toBe('string'); + + const createSavedObjectsResult = await osdTestServer.request + .post(root, '/api/saved_objects/_bulk_create') + .send([mockIndexPattern, mockDashboard]) + .expect(200); + expect(createSavedObjectsResult.body.saved_objects.length).toBe(2); + + const targetWorkspace = createWorkspaceResult.body.result.id; + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body.success).toEqual(true); + expect(result.body.successCount).toEqual(2); + }); + }); }); describe('workspace service api integration test when savedObjects.permission.enabled equal true', () => { diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index bd0b32ce62a0..6c9ff5a0424a 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -93,6 +93,7 @@ export class WorkspacePlugin implements Plugin { - const { maxImportExportSize } = config; +import { + IRouter, + Logger, + exportSavedObjectsToStream, + importSavedObjectsFromStream, +} from '../../../../core/server'; +import { WORKSPACES_API_BASE_URL } from '.'; +import { IWorkspaceClientImpl } from '../types'; +export const registerDuplicateRoute = ( + router: IRouter, + logger: Logger, + client: IWorkspaceClientImpl, + maxImportExportSize: number +) => { router.post( { - path: '/_copy', + path: `${WORKSPACES_API_BASE_URL}/_duplicate_saved_objects`, validate: { body: schema.object({ objects: schema.arrayOf( @@ -23,7 +30,7 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => id: schema.string(), }) ), - includeReferencesDeep: schema.boolean({ defaultValue: false }), + includeReferencesDeep: schema.boolean({ defaultValue: true }), targetWorkspace: schema.string(), }), }, @@ -41,13 +48,31 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => if (invalidObjects.length) { return res.badRequest({ body: { - message: `Trying to copy object(s) with unsupported types: ${invalidObjects + message: `Trying to duplicate object(s) with unsupported types: ${invalidObjects .map((obj) => `${obj.type}:${obj.id}`) .join(', ')}`, }, }); } + // check whether the target workspace exists or not + const getTargetWorkspaceResult = await client.get( + { + context, + request: req, + logger, + }, + targetWorkspace + ); + if (!getTargetWorkspaceResult.success) { + return res.badRequest({ + body: { + message: `Get target workspace ${targetWorkspace} error: ${getTargetWorkspaceResult.error}`, + }, + }); + } + + // fetch all the details of the specified saved objects const objectsListStream = await exportSavedObjectsToStream({ savedObjectsClient, objects, @@ -56,6 +81,7 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => excludeExportDetails: true, }); + // import the saved objects into the target workspace const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 701eb8888130..1693e4636017 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -9,8 +9,9 @@ import { WorkspaceAttributeWithPermission } from '../../../../core/types'; import { WorkspacePermissionMode } from '../../common/constants'; import { IWorkspaceClientImpl } from '../types'; import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { registerDuplicateRoute } from './duplicate'; -const WORKSPACES_API_BASE_URL = '/api/workspaces'; +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; const workspacePermissionMode = schema.oneOf([ schema.literal(WorkspacePermissionMode.Read), @@ -43,12 +44,14 @@ export function registerRoutes({ client, logger, http, + maxImportExportSize, permissionControlClient, isPermissionControlEnabled, }: { client: IWorkspaceClientImpl; logger: Logger; http: CoreSetup['http']; + maxImportExportSize: number; permissionControlClient?: SavedObjectsPermissionControlContract; isPermissionControlEnabled: boolean; }) { @@ -211,4 +214,7 @@ export function registerRoutes({ return res.ok({ body: result }); }) ); + + // duplicate saved objects among workspaces + registerDuplicateRoute(router, logger, client, maxImportExportSize); } From dffd70bb7187c5f4726e36afd8f53724667a68ee Mon Sep 17 00:00:00 2001 From: yuboluo <15242088755@163.com> Date: Fri, 12 Apr 2024 15:05:31 +0800 Subject: [PATCH 3/4] dashboard admin(groups/users) implementation and integrating with dynamic application config (#303) * dashboard admin(groups/users) implementation and add unit/integration test This reverts commit 47e10e4d81ca193028297868beb9eaaced1c00dc. * Add test cases of user Id matching dashboard admin * delete useless code * change isDashboard function name to isRequestByDashboardAdmin * dashboard admin config integrating with dynamic application config Signed-off-by: yubonluo * Optimize the code Signed-off-by: yubonluo * fix test error Signed-off-by: yubonluo * delete useless code Signed-off-by: yubonluo * optimize code Signed-off-by: yubonluo * optimize code and add unit test Signed-off-by: yubonluo * optimize code according to comments Signed-off-by: yubonluo * change dashboardAdmin yml config to openseachDashboard Signed-off-by: yubonluo * add missed code Signed-off-by: yubonluo * optimize code Signed-off-by: yubonluo * delete useless code Signed-off-by: yubonluo * delete useless code Signed-off-by: yubonluo * optimize code Signed-off-by: yubonluo * delete useless code Signed-off-by: yubonluo * add utils unit tests Signed-off-by: yubonluo * optimize code Signed-off-by: yubonluo * Fix the wrong reference Signed-off-by: yubonluo --------- Signed-off-by: yubonluo --- config/opensearch_dashboards.yml | 5 + src/core/server/mocks.ts | 1 + .../server/opensearch_dashboards_config.ts | 8 + .../server/plugins/plugin_context.test.ts | 1 + src/core/server/plugins/types.ts | 1 + src/core/server/utils/workspace.test.ts | 2 + src/core/server/utils/workspace.ts | 4 +- src/legacy/server/config/schema.js | 4 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/server/plugin.test.ts | 14 +- src/plugins/workspace/server/plugin.ts | 67 +++++-- ...space_saved_objects_client_wrapper.test.ts | 188 ++++++++++++++++++ ...space_saved_objects_client_wrapper.test.ts | 162 ++++++++++++++- .../workspace_saved_objects_client_wrapper.ts | 6 + src/plugins/workspace/server/types.ts | 4 + src/plugins/workspace/server/utils.test.ts | 129 +++++++++++- src/plugins/workspace/server/utils.ts | 66 +++++- 17 files changed, 639 insertions(+), 25 deletions(-) diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 11e772087749..46a0c018c6ea 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -290,3 +290,8 @@ # Set the value to true to enable workspace feature # workspace.enabled: false + +# Set the backend roles in groups or users, whoever has the backend roles or exactly match the user ids defined in this config will be regard as dashboard admin. +# Dashboard admin will have the access to all the workspaces(workspace.enabled: true) and objects inside OpenSearch Dashboards. +# opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"] +# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"] \ No newline at end of file diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index dce39d03da7f..87868148e0e8 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -80,6 +80,7 @@ export function pluginInitializerContextConfigMock(config: T) { configIndex: '.opensearch_dashboards_config_tests', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), + dashboardAdmin: { groups: [], users: [] }, }, opensearch: { shardTimeout: duration('30s'), diff --git a/src/core/server/opensearch_dashboards_config.ts b/src/core/server/opensearch_dashboards_config.ts index 47fa8a126501..b823d4f83e2d 100644 --- a/src/core/server/opensearch_dashboards_config.ts +++ b/src/core/server/opensearch_dashboards_config.ts @@ -91,6 +91,14 @@ export const config = { defaultValue: 'https://survey.opensearch.org', }), }), + dashboardAdmin: schema.object({ + groups: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + users: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + }), }), deprecations, }; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 57aa372514de..ac793967d96b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -101,6 +101,7 @@ describe('createPluginInitializerContext', () => { configIndex: '.opensearch_dashboards_config', autocompleteTerminateAfter: duration(100000), autocompleteTimeout: duration(1000), + dashboardAdmin: { groups: [], users: [] }, }, opensearch: { shardTimeout: duration(30, 's'), diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index c225a24aa386..e9c7591f6c56 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -292,6 +292,7 @@ export const SharedGlobalConfigKeys = { 'configIndex', 'autocompleteTerminateAfter', 'autocompleteTimeout', + 'dashboardAdmin', ] as const, opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] as const, path: ['data'] as const, diff --git a/src/core/server/utils/workspace.test.ts b/src/core/server/utils/workspace.test.ts index 7dfcff9e5d18..19f8bad4f866 100644 --- a/src/core/server/utils/workspace.test.ts +++ b/src/core/server/utils/workspace.test.ts @@ -11,9 +11,11 @@ describe('updateWorkspaceState', () => { const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); updateWorkspaceState(requestMock, { requestWorkspaceId: 'foo', + isDashboardAdmin: true, }); expect(getWorkspaceState(requestMock)).toEqual({ requestWorkspaceId: 'foo', + isDashboardAdmin: true, }); }); }); diff --git a/src/core/server/utils/workspace.ts b/src/core/server/utils/workspace.ts index 2003e615d501..89f2b7975964 100644 --- a/src/core/server/utils/workspace.ts +++ b/src/core/server/utils/workspace.ts @@ -7,6 +7,7 @@ import { OpenSearchDashboardsRequest, ensureRawRequest } from '../http/router'; export interface WorkspaceState { requestWorkspaceId?: string; + isDashboardAdmin?: boolean; } /** @@ -29,8 +30,9 @@ export const updateWorkspaceState = ( }; export const getWorkspaceState = (request: OpenSearchDashboardsRequest): WorkspaceState => { - const { requestWorkspaceId } = ensureRawRequest(request).app as WorkspaceState; + const { requestWorkspaceId, isDashboardAdmin } = ensureRawRequest(request).app as WorkspaceState; return { requestWorkspaceId, + isDashboardAdmin, }; }; diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a102268effca..84d457f06ca4 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -251,6 +251,10 @@ export default () => survey: Joi.object({ url: Joi.any().default('/'), }), + dashboardAdmin: Joi.object({ + groups: Joi.array().items(Joi.string()).default([]), + users: Joi.array().items(Joi.string()).default([]), + }), }).default(), savedObjects: HANDLED_IN_NEW_PLATFORM, diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 7d94a7491a00..039486a22cbf 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -6,6 +6,6 @@ "requiredPlugins": [ "savedObjects" ], - "optionalPlugins": ["savedObjectsManagement"], + "optionalPlugins": ["savedObjectsManagement", "applicationConfig"], "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 0ad72b51b6dc..f3f13fa27513 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -6,9 +6,17 @@ import { OnPreRoutingHandler } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; +import { AppPluginSetupDependencies } from './types'; import { getWorkspaceState } from '../../../core/server/utils'; describe('Workspace server plugin', () => { + const mockApplicationConfig = { + getConfigurationClient: jest.fn().mockResolvedValue({}), + registerConfigurationClient: jest.fn().mockResolvedValue({}), + }; + const mockDependencies: AppPluginSetupDependencies = { + applicationConfig: mockApplicationConfig, + }; it('#setup', async () => { let value; const setupMock = coreMock.createSetup(); @@ -17,7 +25,7 @@ describe('Workspace server plugin', () => { }); setupMock.capabilities.registerProvider.mockImplementationOnce((fn) => (value = fn())); const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, mockDependencies); expect(value).toMatchInlineSnapshot(` Object { "workspaces": Object { @@ -43,7 +51,7 @@ describe('Workspace server plugin', () => { return fn; }); const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, mockDependencies); const toolKitMock = httpServerMock.createToolkit(); const requestWithWorkspaceInUrl = httpServerMock.createOpenSearchDashboardsRequest({ @@ -78,7 +86,7 @@ describe('Workspace server plugin', () => { }); const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, mockDependencies); await workspacePlugin.start(startMock); expect(startMock.savedObjects.createSerializer).toBeCalledTimes(1); }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 6c9ff5a0424a..b4601d5d71dc 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -18,7 +18,12 @@ import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_ID_CONSUMER_WRAPPER_ID, } from '../common/constants'; -import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; +import { + IWorkspaceClientImpl, + WorkspacePluginSetup, + WorkspacePluginStart, + AppPluginSetupDependencies, +} from './types'; import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; @@ -32,6 +37,11 @@ import { SavedObjectsPermissionControl, SavedObjectsPermissionControlContract, } from './permission_control/client'; +import { + getApplicationOSDAdminConfig, + getOSDAdminConfig, + updateDashboardAdminStateForRequest, +} from './utils'; import { WorkspaceIdConsumerWrapper } from './saved_objects/workspace_id_consumer_wrapper'; export class WorkspacePlugin implements Plugin { @@ -64,12 +74,51 @@ export class WorkspacePlugin implements Plugin { + let groups: string[]; + let users: string[]; + let configGroups: string[]; + let configUsers: string[]; + + // There may be calls to saved objects client before user get authenticated, need to add a try catch here as `getPrincipalsFromRequest` will throw error when user is not authenticated. + try { + ({ groups = [], users = [] } = this.permissionControl!.getPrincipalsFromRequest(request)); + } catch (e) { + return toolkit.next(); + } + + if (!!applicationConfig) { + [configGroups, configUsers] = await getApplicationOSDAdminConfig( + { applicationConfig }, + request + ); + } else { + [configGroups, configUsers] = await getOSDAdminConfig(this.globalConfig$); + } + updateDashboardAdminStateForRequest(request, groups, users, configGroups, configUsers); + return toolkit.next(); + }); + + this.workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( + this.permissionControl + ); + + core.savedObjects.addClientWrapper( + 0, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + this.workspaceSavedObjectsClientWrapper.wrapperFactory + ); + } + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); this.globalConfig$ = initializerContext.config.legacy.globalConfig$; } - public async setup(core: CoreSetup) { + public async setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) { this.logger.debug('Setting up Workspaces service'); const globalConfig = await this.globalConfig$.pipe(first()).toPromise(); const isPermissionControlEnabled = globalConfig.savedObjects.permission.enabled === true; @@ -95,19 +144,7 @@ export class WorkspacePlugin implements Plugin { const savedObjects: Array<{ type: string; id: string }> = []; @@ -51,6 +52,7 @@ const repositoryKit = (() => { const permittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); const notPermittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); +const dashboardAdminRequest = httpServerMock.createOpenSearchDashboardsRequest(); describe('WorkspaceSavedObjectsClientWrapper', () => { let internalSavedObjectsRepository: ISavedObjectsRepository; @@ -59,6 +61,7 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { let osd: TestOpenSearchDashboardsUtils; let permittedSavedObjectedClient: SavedObjectsClientContract; let notPermittedSavedObjectedClient: SavedObjectsClientContract; + let dashboardAdminSavedObjectedClient: SavedObjectsClientContract; beforeAll(async function () { servers = createTestServers({ @@ -133,6 +136,10 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { notPermittedSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient( notPermittedRequest ); + updateWorkspaceState(dashboardAdminRequest, { isDashboardAdmin: true }); + dashboardAdminSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient( + dashboardAdminRequest + ); }); afterAll(async () => { @@ -172,6 +179,17 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { (await permittedSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')).error ).toBeUndefined(); }); + + it('should return consistent dashboard when groups/users is dashboard admin', async () => { + expect( + (await dashboardAdminSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1')) + .error + ).toBeUndefined(); + expect( + (await dashboardAdminSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')) + .error + ).toBeUndefined(); + }); }); describe('bulkGet', () => { @@ -215,6 +233,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { ).saved_objects.length ).toEqual(1); }); + + it('should return consistent dashboard when groups/users is dashboard admin', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1' }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await dashboardAdminSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'acl-controlled-dashboard-2' }, + ]) + ).saved_objects.length + ).toEqual(1); + }); }); describe('find', () => { @@ -246,6 +281,19 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { true ); }); + + it('should return consistent inner workspace data when groups/users is dashboard admin', async () => { + const result = await dashboardAdminSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }); + + expect(result.saved_objects.some((item) => item.id === 'inner-workspace-dashboard-1')).toBe( + true + ); + }); }); describe('create', () => { @@ -278,6 +326,18 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await permittedSavedObjectedClient.delete('dashboard', createResult.id); }); + it('should able to create saved objects into any workspaces after create called when groups/users is dashboard admin', async () => { + const createResult = await dashboardAdminSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + expect(createResult.error).toBeUndefined(); + await dashboardAdminSavedObjectedClient.delete('dashboard', createResult.id); + }); + it('should throw forbidden error when create with override', async () => { let error; try { @@ -309,6 +369,20 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(createResult.error).toBeUndefined(); }); + + it('should able to create with override when groups/users is dashboard admin', async () => { + const createResult = await dashboardAdminSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.error).toBeUndefined(); + }); }); describe('bulkCreate', () => { @@ -337,6 +411,18 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await permittedSavedObjectedClient.delete('dashboard', objectId); }); + it('should able to create saved objects into any workspaces after bulkCreate called when groups/users is dashboard damin', async () => { + const objectId = new Date().getTime().toString(16).toUpperCase(); + const result = await dashboardAdminSavedObjectedClient.bulkCreate( + [{ type: 'dashboard', attributes: {}, id: objectId }], + { + workspaces: ['workspace-1'], + } + ); + expect(result.saved_objects.length).toEqual(1); + await dashboardAdminSavedObjectedClient.delete('dashboard', objectId); + }); + it('should throw forbidden error when create with override', async () => { let error; try { @@ -377,6 +463,24 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(createResult.saved_objects).toHaveLength(1); }); + + it('should able to bulk create with override when groups/users is dashboard admin', async () => { + const createResult = await dashboardAdminSavedObjectedClient.bulkCreate( + [ + { + id: 'inner-workspace-dashboard-1', + type: 'dashboard', + attributes: {}, + }, + ], + { + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.saved_objects).toHaveLength(1); + }); }); describe('update', () => { @@ -414,6 +518,27 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { .error ).toBeUndefined(); }); + + it('should update saved objects for any workspaces when groups/users is dashboard admin', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.update( + 'dashboard', + 'inner-workspace-dashboard-1', + {} + ) + ).error + ).toBeUndefined(); + expect( + ( + await dashboardAdminSavedObjectedClient.update( + 'dashboard', + 'acl-controlled-dashboard-2', + {} + ) + ).error + ).toBeUndefined(); + }); }); describe('bulkUpdate', () => { @@ -459,6 +584,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { ).saved_objects.length ).toEqual(1); }); + + it('should bulk update saved objects for any workspaces when groups/users is dashboard admin', async () => { + expect( + ( + await dashboardAdminSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await dashboardAdminSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + }); }); describe('delete', () => { @@ -526,6 +668,52 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { } expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); }); + + it('should be able to delete any data when groups/users is dashboard admin', async () => { + const createPermittedResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + permissions: { + read: { users: ['foo'] }, + write: { users: ['foo'] }, + }, + } + ); + + await dashboardAdminSavedObjectedClient.delete('dashboard', createPermittedResult.id); + + let permittedError; + try { + permittedError = await dashboardAdminSavedObjectedClient.get( + 'dashboard', + createPermittedResult.id + ); + } catch (e) { + permittedError = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(permittedError)).toBe(true); + + const createACLResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + + await dashboardAdminSavedObjectedClient.delete('dashboard', createACLResult.id); + + let ACLError; + try { + ACLError = await dashboardAdminSavedObjectedClient.get('dashboard', createACLResult.id); + } catch (e) { + ACLError = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(ACLError)).toBe(true); + }); }); describe('deleteByWorkspace', () => { diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts index 07d1e6aff40c..c000d72a2f7a 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -3,10 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { getWorkspaceState, updateWorkspaceState } from '../../../../core/server/utils'; import { SavedObjectsErrorHelpers } from '../../../../core/server'; import { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; +import { httpServerMock } from '../../../../core/server/mocks'; -const generateWorkspaceSavedObjectsClientWrapper = () => { +const DASHBOARD_ADMIN = 'dashnoard_admin'; +const NO_DASHBOARD_ADMIN = 'no_dashnoard_admin'; + +const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) => { const savedObjectsStore = [ { type: 'dashboard', @@ -75,7 +80,8 @@ const generateWorkspaceSavedObjectsClientWrapper = () => { find: jest.fn(), deleteByWorkspace: jest.fn(), }; - const requestMock = {}; + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + if (role === DASHBOARD_ADMIN) updateWorkspaceState(requestMock, { isDashboardAdmin: true }); const wrapperOptions = { client: clientMock, request: requestMock, @@ -91,8 +97,11 @@ const generateWorkspaceSavedObjectsClientWrapper = () => { }), validateSavedObjectsACL: jest.fn(), batchValidate: jest.fn(), - getPrincipalsFromRequest: jest.fn().mockImplementation(() => ({ users: ['user-1'] })), + getPrincipalsFromRequest: jest.fn().mockImplementation(() => { + return { users: ['user-1'] }; + }), }; + const wrapper = new WorkspaceSavedObjectsClientWrapper(permissionControlMock); const scopedClientMock = { find: jest.fn().mockImplementation(async () => ({ @@ -152,6 +161,21 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.delete(...deleteArgs); expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); }); + it('should call client.delete if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + expect(getWorkspaceState(requestMock)).toEqual({ + isDashboardAdmin: true, + }); + const deleteArgs = ['dashboard', 'not-permitted-dashboard'] as const; + await wrapper.delete(...deleteArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); + }); }); describe('update', () => { @@ -206,6 +230,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.update(...updateArgs); expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); }); + it('should call client.update if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const updateArgs = [ + 'dashboard', + 'not-permitted-dashboard', + { + bar: 'for', + }, + ] as const; + await wrapper.update(...updateArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); + }); }); describe('bulk update', () => { @@ -241,6 +282,19 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.bulkUpdate(objectsToUpdate, {}); expect(clientMock.bulkUpdate).toHaveBeenCalledWith(objectsToUpdate, {}); }); + it('should call client.bulkUpdate if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const bulkUpdateArgs = [ + { type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }, + ]; + await wrapper.bulkUpdate(bulkUpdateArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.bulkUpdate).toHaveBeenCalledWith(bulkUpdateArgs); + }); }); describe('bulk create', () => { @@ -343,6 +397,25 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { workspaces: ['workspace-1'], }); }); + it('should call client.bulkCreate if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const objectsToBulkCreate = [ + { type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }, + ]; + await wrapper.bulkCreate(objectsToBulkCreate, { + overwrite: true, + workspaces: ['not-permitted-workspace'], + }); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + workspaces: ['not-permitted-workspace'], + }); + }); }); describe('create', () => { @@ -417,6 +490,30 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { } ); }); + it('should call client.create if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + await wrapper.create( + 'dashboard', + { foo: 'bar' }, + { + id: 'not-permitted-dashboard', + overwrite: true, + } + ); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(clientMock.create).toHaveBeenCalledWith( + 'dashboard', + { foo: 'bar' }, + { + id: 'not-permitted-dashboard', + overwrite: true, + } + ); + }); }); describe('get', () => { it('should return saved object if no need to validate permission', async () => { @@ -478,6 +575,18 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(clientMock.get).toHaveBeenCalledWith(...getArgs); expect(result).toMatchInlineSnapshot(`[Error: Not Found]`); }); + it('should call client.get and return result with arguments if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const getArgs = ['dashboard', 'not-permitted-dashboard'] as const; + const result = await wrapper.get(...getArgs); + expect(clientMock.get).toHaveBeenCalledWith(...getArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(result.id).toBe('not-permitted-dashboard'); + }); }); describe('bulk get', () => { it("should call permission validate with object's workspace and throw permission error", async () => { @@ -543,6 +652,27 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { {} ); }); + it('should call client.bulkGet and return result with arguments if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + const bulkGetArgs = [ + { + type: 'dashboard', + id: 'foo', + }, + { + type: 'dashboard', + id: 'not-permitted-dashboard', + }, + ]; + const result = await wrapper.bulkGet(bulkGetArgs); + expect(clientMock.bulkGet).toHaveBeenCalledWith(bulkGetArgs); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + expect(result.saved_objects.length).toBe(2); + }); }); describe('find', () => { it('should call client.find with ACLSearchParams for workspace type', async () => { @@ -634,6 +764,22 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { }, }); }); + it('should call client.find with arguments if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + await wrapper.find({ + type: 'dashboard', + workspaces: ['workspace-1', 'not-permitted-workspace'], + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'dashboard', + workspaces: ['workspace-1', 'not-permitted-workspace'], + }); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); }); describe('deleteByWorkspace', () => { it('should call permission validate with workspace and throw workspace permission error if not permitted', async () => { @@ -662,6 +808,16 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { await wrapper.deleteByWorkspace('workspace-1', {}); expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('workspace-1', {}); }); + it('should call client.deleteByWorkspace if user/groups is dashboard admin', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); + await wrapper.deleteByWorkspace('not-permitted-workspace'); + expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('not-permitted-workspace'); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); }); }); }); 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 4d5d03641b5f..3101b74598bb 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 @@ -5,6 +5,7 @@ import { i18n } from '@osd/i18n'; +import { getWorkspaceState } from '../../../../core/server/utils'; import { OpenSearchDashboardsRequest, SavedObject, @@ -519,6 +520,11 @@ export class WorkspaceSavedObjectsClientWrapper { return await wrapperOptions.client.deleteByWorkspace(workspace, options); }; + const isDashboardAdmin = getWorkspaceState(wrapperOptions.request)?.isDashboardAdmin; + if (isDashboardAdmin) { + return wrapperOptions.client; + } + return { ...wrapperOptions.client, get: getWithWorkspacePermissionControl, diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index b506bb493a4c..6a9009a06375 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ApplicationConfigPluginSetup } from 'src/plugins/application_config/server'; import { Logger, OpenSearchDashboardsRequest, @@ -127,6 +128,9 @@ export interface AuthInfo { user_name?: string; } +export interface AppPluginSetupDependencies { + applicationConfig: ApplicationConfigPluginSetup; +} export interface WorkspacePluginSetup { client: IWorkspaceClientImpl; } diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 1f6c3e58f122..639dfb3963e5 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -5,7 +5,17 @@ import { AuthStatus } from '../../../core/server'; import { httpServerMock, httpServiceMock } from '../../../core/server/mocks'; -import { generateRandomId, getPrincipalsFromRequest } from './utils'; +import { + generateRandomId, + getApplicationOSDAdminConfig, + getOSDAdminConfig, + getPrincipalsFromRequest, + stringToArray, + updateDashboardAdminStateForRequest, +} from './utils'; +import { getWorkspaceState } from '../../../core/server/utils'; +import { AppPluginSetupDependencies } from './types'; +import { Observable, of } from 'rxjs'; describe('workspace utils', () => { const mockAuth = httpServiceMock.createAuth(); @@ -73,4 +83,121 @@ describe('workspace utils', () => { 'UNEXPECTED_AUTHORIZATION_STATUS' ); }); + + it('should be dashboard admin when users match configUsers', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = ['dashboard_admin']; + const users: string[] = []; + const configGroups: string[] = ['dashboard_admin']; + const configUsers: string[] = []; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(true); + }); + + it('should be dashboard admin when groups match configGroups', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = []; + const users: string[] = ['dashboard_admin']; + const configGroups: string[] = []; + const configUsers: string[] = ['dashboard_admin']; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(true); + }); + + it('should be not dashboard admin when groups do not match configGroups', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = ['dashboard_admin']; + const users: string[] = []; + const configGroups: string[] = []; + const configUsers: string[] = ['dashboard_admin']; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(false); + }); + + it('should be not dashboard admin when groups and users are []', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const groups: string[] = []; + const users: string[] = []; + const configGroups: string[] = []; + const configUsers: string[] = []; + updateDashboardAdminStateForRequest(mockRequest, groups, users, configGroups, configUsers); + expect(getWorkspaceState(mockRequest)?.isDashboardAdmin).toBe(false); + }); + + it('should convert string to array', () => { + const jsonString = '["test1","test2"]'; + const strToArray = stringToArray(jsonString); + expect(strToArray).toStrictEqual(new Array('test1', 'test2')); + }); + + it('should convert string to a null array if input is invalid', () => { + const jsonString = '["test1", test2]'; + const strToArray = stringToArray(jsonString); + expect(strToArray).toStrictEqual([]); + }); + + it('should get correct OSD admin config when application config is enabled', async () => { + const applicationConfigMock = { + getConfigurationClient: jest.fn().mockReturnValue({ + getEntityConfig: jest.fn().mockImplementation(async (entity: string) => { + if (entity === 'opensearchDashboards.dashboardAdmin.groups') { + return '["group1", "group2"]'; + } else if (entity === 'opensearchDashboards.dashboardAdmin.users') { + return '["user1", "user2"]'; + } else { + return undefined; + } + }), + }), + registerConfigurationClient: jest.fn().mockResolvedValue({}), + }; + + const mockDependencies: AppPluginSetupDependencies = { + applicationConfig: applicationConfigMock, + }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const [groups, users] = await getApplicationOSDAdminConfig(mockDependencies, mockRequest); + expect(groups).toEqual(['group1', 'group2']); + expect(users).toEqual(['user1', 'user2']); + }); + + it('should get [] when application config is enabled and not defined ', async () => { + const applicationConfigMock = { + getConfigurationClient: jest.fn().mockReturnValue({ + getEntityConfig: jest.fn().mockImplementation(async (entity: string) => { + throw new Error('Not found'); + }), + }), + registerConfigurationClient: jest.fn().mockResolvedValue({}), + }; + + const mockDependencies: AppPluginSetupDependencies = { + applicationConfig: applicationConfigMock, + }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const [groups, users] = await getApplicationOSDAdminConfig(mockDependencies, mockRequest); + expect(groups).toEqual([]); + expect(users).toEqual([]); + }); + + it('should get correct admin config when admin config is enabled ', async () => { + const globalConfig$: Observable = of({ + opensearchDashboards: { + dashboardAdmin: { + groups: ['group1', 'group2'], + users: ['user1', 'user2'], + }, + }, + }); + const [groups, users] = await getOSDAdminConfig(globalConfig$); + expect(groups).toEqual(['group1', 'group2']); + expect(users).toEqual(['user1', 'user2']); + }); + + it('should get [] when admin config is not enabled', async () => { + const globalConfig$: Observable = of({}); + const [groups, users] = await getOSDAdminConfig(globalConfig$); + expect(groups).toEqual([]); + expect(users).toEqual([]); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 1c8d73953afa..79fcc60aad5d 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -4,14 +4,18 @@ */ import crypto from 'crypto'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { AuthStatus, HttpAuth, OpenSearchDashboardsRequest, Principals, PrincipalType, + SharedGlobalConfig, } from '../../../core/server'; -import { AuthInfo } from './types'; +import { AppPluginSetupDependencies, AuthInfo } from './types'; +import { updateWorkspaceState } from '../../../core/server/utils'; /** * Generate URL friendly random ID @@ -50,3 +54,63 @@ export const getPrincipalsFromRequest = ( throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); }; + +export const updateDashboardAdminStateForRequest = ( + request: OpenSearchDashboardsRequest, + groups: string[], + users: string[], + configGroups: string[], + configUsers: string[] +) => { + if (configGroups.length === 0 && configUsers.length === 0) { + updateWorkspaceState(request, { + isDashboardAdmin: false, + }); + return; + } + const groupMatchAny = groups.some((group) => configGroups.includes(group)) || false; + const userMatchAny = users.some((user) => configUsers.includes(user)) || false; + updateWorkspaceState(request, { + isDashboardAdmin: groupMatchAny || userMatchAny, + }); +}; + +export const stringToArray = (adminConfig: string | undefined) => { + if (!adminConfig) { + return []; + } + let adminConfigArray; + try { + adminConfigArray = JSON.parse(adminConfig); + } catch (e) { + return []; + } + return adminConfigArray; +}; + +export const getApplicationOSDAdminConfig = async ( + { applicationConfig }: AppPluginSetupDependencies, + request: OpenSearchDashboardsRequest +) => { + const applicationConfigClient = applicationConfig.getConfigurationClient(request); + + const [groupsResult, usersResult] = await Promise.all([ + applicationConfigClient + .getEntityConfig('opensearchDashboards.dashboardAdmin.groups') + .catch(() => undefined), + applicationConfigClient + .getEntityConfig('opensearchDashboards.dashboardAdmin.users') + .catch(() => undefined), + ]); + + return [stringToArray(groupsResult), stringToArray(usersResult)]; +}; + +export const getOSDAdminConfig = async (globalConfig$: Observable) => { + const globalConfig = await globalConfig$.pipe(first()).toPromise(); + const groupsResult = (globalConfig.opensearchDashboards?.dashboardAdmin?.groups || + []) as string[]; + const usersResult = (globalConfig.opensearchDashboards?.dashboardAdmin?.users || []) as string[]; + + return [groupsResult, usersResult]; +}; From 2c0827cdd71904d7861352e3bd06ececc7882b35 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 12 Apr 2024 16:20:32 +0800 Subject: [PATCH 4/4] [Workspace] Jump to non-workspace url when clicking home icon (#316) * temp: save Signed-off-by: SuZhou-Joe * feat: complete the feature Signed-off-by: SuZhou-Joe * feat: remove useless code Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * fix: page not found error Signed-off-by: SuZhou-Joe * fix: anchor href Signed-off-by: SuZhou-Joe * feat: update toNavLink to comply with workspace Signed-off-by: SuZhou-Joe * feat: change to workspaceless Signed-off-by: SuZhou-Joe * feat: change to workspaceless Signed-off-by: SuZhou-Joe * feat: change to workspaceless Signed-off-by: SuZhou-Joe * feat: register list and create page as workspaceless Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: update to WorkspaceVisibility Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: optimize the jump logic Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: make app inaccessible if workspaceAccessibility is No Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../application/application_service.test.ts | 94 ++++++++++++++++++- .../application/application_service.tsx | 26 ++++- src/core/public/application/index.ts | 1 + .../application_service.test.tsx | 7 +- src/core/public/application/types.ts | 22 +++++ src/core/public/chrome/chrome_service.tsx | 2 +- .../chrome/nav_links/to_nav_link.test.ts | 41 +++++++- .../public/chrome/nav_links/to_nav_link.ts | 12 ++- src/core/public/core_system.ts | 2 +- src/core/public/index.ts | 1 + src/plugins/home/public/plugin.ts | 3 +- .../public/plugin.ts | 2 + .../workspace_menu/workspace_menu.test.tsx | 4 +- .../workspace_menu/workspace_menu.tsx | 16 +--- src/plugins/workspace/public/plugin.ts | 3 + src/plugins/workspace/public/utils.test.ts | 16 +++- src/plugins/workspace/public/utils.ts | 8 ++ 17 files changed, 230 insertions(+), 30 deletions(-) diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 691ba64cf00a..ca15c76377d4 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -44,8 +44,16 @@ import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; -import { App, PublicAppInfo, AppNavLinkStatus, AppStatus, AppUpdater } from './types'; +import { + App, + PublicAppInfo, + AppNavLinkStatus, + AppStatus, + AppUpdater, + WorkspaceAccessibility, +} from './types'; import { act } from 'react-dom/test-utils'; +import { workspacesServiceMock } from '../mocks'; const createApp = (props: Partial): App => { return { @@ -68,7 +76,11 @@ describe('#setup()', () => { context: contextServiceMock.createSetupContract(), redirectTo: jest.fn(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); @@ -398,7 +410,11 @@ describe('#start()', () => { context: contextServiceMock.createSetupContract(), redirectTo: jest.fn(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); @@ -539,6 +555,32 @@ describe('#start()', () => { 'http://localhost/base-path/app/app2/deep/link' ); }); + + it('creates URL when the app is not accessible in a workspace', async () => { + const httpMock = httpServiceMock.createSetupContract({ + basePath: '/base-path', + clientBasePath: '/client-base-path', + }); + const { register } = service.setup({ + ...setupDeps, + http: httpMock, + }); + // register a app that can only be accessed out of a workspace + register( + Symbol(), + createApp({ + id: 'app1', + workspaceAccessibility: WorkspaceAccessibility.NO, + }) + ); + const { getUrlForApp } = await service.start({ + ...startDeps, + http: httpMock, + }); + + expect(getUrlForApp('app1')).toBe('/base-path/app/app1'); + expect(getUrlForApp('app2')).toBe('/base-path/client-base-path/app/app2'); + }); }); describe('navigateToApp', () => { @@ -766,6 +808,46 @@ describe('#start()', () => { `); }); + it('navigate by using window.location.assign if navigate to a app not accessible within a workspace', async () => { + // Save the original assign method + const originalLocation = window.location; + delete (window as any).location; + + // Mocking the window object + window.location = { + ...originalLocation, + assign: jest.fn(), + }; + + const { register } = service.setup(setupDeps); + // register a app that can only be accessed out of a workspace + register( + Symbol(), + createApp({ + id: 'app1', + workspaceAccessibility: WorkspaceAccessibility.NO, + }) + ); + const workspaces = workspacesServiceMock.createStartContract(); + workspaces.currentWorkspaceId$.next('foo'); + const http = httpServiceMock.createStartContract({ + basePath: '/base-path', + clientBasePath: '/client-base-path', + }); + const { navigateToApp } = await service.start({ + ...startDeps, + workspaces, + http, + }); + await navigateToApp('app1'); + + expect(window.location.assign).toBeCalledWith('/base-path/app/app1'); + await navigateToApp('app2'); + // assign should not be called + expect(window.location.assign).toBeCalledTimes(1); + window.location = originalLocation; + }); + describe('when `replace` option is true', () => { it('use `history.replace` instead of `history.push`', async () => { service.setup(setupDeps); @@ -869,7 +951,11 @@ describe('#stop()', () => { http, context: contextServiceMock.createSetupContract(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 62c13694e245..3dca2cfd46a1 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -54,9 +54,11 @@ import { InternalApplicationStart, Mounter, NavigateToAppOptions, + WorkspaceAccessibility, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils'; +import { WorkspacesStart } from '../workspace'; interface SetupDeps { context: ContextSetup; @@ -69,6 +71,7 @@ interface SetupDeps { interface StartDeps { http: HttpStart; overlays: OverlayStart; + workspaces: WorkspacesStart; } // Mount functions with two arguments are assumed to expect deprecated `context` object. @@ -213,7 +216,7 @@ export class ApplicationService { }; } - public async start({ http, overlays }: StartDeps): Promise { + public async start({ http, overlays, workspaces }: StartDeps): Promise { if (!this.mountContext) { throw new Error('ApplicationService#setup() must be invoked before start.'); } @@ -259,6 +262,22 @@ export class ApplicationService { const shouldNavigate = navigatingToSameApp ? true : await this.shouldNavigate(overlays); if (shouldNavigate) { + const targetApp = applications$.value.get(appId); + if ( + workspaces.currentWorkspaceId$.value && + targetApp?.workspaceAccessibility === WorkspaceAccessibility.NO + ) { + // If user is inside a workspace and the target app is not accessible within a workspace + // refresh the page by doing a hard navigation + window.location.assign( + http.basePath.prepend(getAppUrl(availableMounters, appId, path), { + // Set withoutClientBasePath to true remove the workspace path prefix + withoutClientBasePath: true, + }) + ); + return; + } + if (path === undefined) { path = applications$.value.get(appId)?.defaultPath; } @@ -293,7 +312,10 @@ export class ApplicationService { appId, { path, absolute = false }: { path?: string; absolute?: boolean } = {} ) => { - const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path)); + const targetApp = applications$.value.get(appId); + const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path), { + withoutClientBasePath: targetApp?.workspaceAccessibility === WorkspaceAccessibility.NO, + }); return absolute ? relativeToAbsolute(relUrl) : relUrl; }, navigateToApp, diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index b1a9f47b72e0..790c6ac9240c 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -54,4 +54,5 @@ export { // Internal types InternalApplicationSetup, InternalApplicationStart, + WorkspaceAccessibility, } from './types'; diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 9d53d99c9d8c..a463dec892f3 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -41,6 +41,7 @@ import { overlayServiceMock } from '../../overlays/overlay_service.mock'; import { AppMountParameters } from '../types'; import { Observable } from 'rxjs'; import { MountPoint } from 'opensearch-dashboards/public'; +import { workspacesServiceMock } from '../../mocks'; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); @@ -67,7 +68,11 @@ describe('ApplicationService', () => { context: contextServiceMock.createSetupContract(), history: history as any, }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 4744ab34cfd3..977489fda37a 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -103,6 +103,22 @@ export type AppUpdatableFields = Pick Partial | undefined; +/** + * Visibilities of the application based on if user is within a workspace + * + * @public + */ +export enum WorkspaceAccessibility { + /** + * The application is not accessible when user is in a workspace. + */ + NO = 0, + /** + * The application is only accessible when user is in a workspace. + */ + YES = 1, +} + /** * @public */ @@ -245,6 +261,12 @@ export interface App { * ``` */ exactRoute?: boolean; + + /** + * Declare if page is accessible when inside a workspace. + * Defaults to undefined to indicate the application can be accessible within or out of workspace. + */ + workspaceAccessibility?: WorkspaceAccessibility; } /** diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 57c9f11d9061..59ca1d29be50 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -264,7 +264,7 @@ export class ChromeService { forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))} - homeHref={http.basePath.prepend('/app/home')} + homeHref={application.getUrlForApp('home')} isVisible$={this.isVisible$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts index 1fe2532b7d8f..85ff753a40d9 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.test.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -28,7 +28,12 @@ * under the License. */ -import { PublicAppInfo, AppNavLinkStatus, AppStatus } from '../../application'; +import { + PublicAppInfo, + AppNavLinkStatus, + AppStatus, + WorkspaceAccessibility, +} from '../../application'; import { toNavLink } from './to_nav_link'; import { httpServiceMock } from '../../mocks'; @@ -174,4 +179,38 @@ describe('toNavLink', () => { }) ); }); + + it('uses the workspaceVisibility of the application to construct the url', () => { + const httpMock = httpServiceMock.createStartContract({ + basePath: '/base_path', + clientBasePath: '/client_base_path', + }); + expect( + toNavLink( + app({ + workspaceAccessibility: WorkspaceAccessibility.NO, + }), + httpMock.basePath + ).properties + ).toEqual( + expect.objectContaining({ + url: 'http://localhost/base_path/app/some-id', + baseUrl: 'http://localhost/base_path/app/some-id', + }) + ); + + expect( + toNavLink( + app({ + workspaceAccessibility: WorkspaceAccessibility.YES, + }), + httpMock.basePath + ).properties + ).toEqual( + expect.objectContaining({ + url: 'http://localhost/base_path/client_base_path/app/some-id', + baseUrl: 'http://localhost/base_path/client_base_path/app/some-id', + }) + ); + }); }); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts index 734bb114d73d..3b03b6d9278e 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -28,14 +28,22 @@ * under the License. */ -import { PublicAppInfo, AppNavLinkStatus, AppStatus } from '../../application'; +import { + PublicAppInfo, + AppNavLinkStatus, + AppStatus, + WorkspaceAccessibility, +} from '../../application'; import { IBasePath } from '../../http'; import { NavLinkWrapper } from './nav_link'; import { appendAppPath } from '../../application/utils'; export function toNavLink(app: PublicAppInfo, basePath: IBasePath): NavLinkWrapper { const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; - const relativeBaseUrl = basePath.prepend(app.appRoute!); + let relativeBaseUrl = basePath.prepend(app.appRoute!); + if (app.workspaceAccessibility === WorkspaceAccessibility.NO) { + relativeBaseUrl = basePath.prepend(app.appRoute!, { withoutClientBasePath: true }); + } const url = relativeToAbsolute(appendAppPath(relativeBaseUrl, app.defaultPath)); const baseUrl = relativeToAbsolute(relativeBaseUrl); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index e70966620c82..7f95f1d45643 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -226,8 +226,8 @@ export class CoreSystem { overlays, targetDomElement: notificationsTargetDomElement, }); - const application = await this.application.start({ http, overlays }); const workspaces = this.workspaces.start(); + const application = await this.application.start({ http, overlays, workspaces }); const chrome = await this.chrome.start({ application, docLinks, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 0dc37587032e..9d23545c20fe 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -130,6 +130,7 @@ export { AppUpdater, ScopedHistory, NavigateToAppOptions, + WorkspaceAccessibility, } from './application'; export { diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index bf815a30c74d..95f1a511d43a 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -54,7 +54,7 @@ import { DataPublicPluginStart } from '../../data/public'; import { TelemetryPluginStart } from '../../telemetry/public'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; -import { AppNavLinkStatus } from '../../../core/public'; +import { AppNavLinkStatus, WorkspaceAccessibility } from '../../../core/public'; import { PLUGIN_ID, HOME_APP_BASE_PATH } from '../common/constants'; import { DataSourcePluginStart } from '../../data_source/public'; @@ -130,6 +130,7 @@ export class HomePublicPlugin const { renderApp } = await import('./application'); return await renderApp(params.element, coreStart, params.history); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); urlForwarding.forwardApp('home', 'home'); diff --git a/src/plugins/opensearch_dashboards_overview/public/plugin.ts b/src/plugins/opensearch_dashboards_overview/public/plugin.ts index e38282ff06d6..69b0e9616452 100644 --- a/src/plugins/opensearch_dashboards_overview/public/plugin.ts +++ b/src/plugins/opensearch_dashboards_overview/public/plugin.ts @@ -40,6 +40,7 @@ import { AppStatus, AppNavLinkStatus, Branding, + WorkspaceAccessibility, } from '../../../core/public'; import { OpenSearchDashboardsOverviewPluginSetup, @@ -106,6 +107,7 @@ export class OpenSearchDashboardsOverviewPlugin // Render the application return renderApp(coreStart, depsStart as AppPluginStartDependencies, params); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); if (home) { diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx index c63b232bb232..31682b6f649a 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -93,7 +93,7 @@ describe('', () => { render(); fireEvent.click(screen.getByText(/select a workspace/i)); fireEvent.click(screen.getByText(/create workspace/i)); - expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_create'); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_create'); Object.defineProperty(window, 'location', { value: originalLocation, @@ -111,7 +111,7 @@ describe('', () => { render(); fireEvent.click(screen.getByText(/select a workspace/i)); fireEvent.click(screen.getByText(/all workspace/i)); - expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_list'); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); Object.defineProperty(window, 'location', { value: originalLocation, diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index a5b250e6b89c..495c35731450 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -101,13 +101,7 @@ export const WorkspaceMenu = ({ coreStart }: Props) => { }), key: WORKSPACE_CREATE_APP_ID, onClick: () => { - window.location.assign( - cleanWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { - absolute: false, - }) - ) - ); + coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); }, }); workspaceListItems.push({ @@ -117,13 +111,7 @@ export const WorkspaceMenu = ({ coreStart }: Props) => { }), key: WORKSPACE_LIST_APP_ID, onClick: () => { - window.location.assign( - cleanWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_LIST_APP_ID, { - absolute: false, - }) - ) - ); + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); }, }); return workspaceListItems; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 95e684610b42..240e10c24570 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -14,6 +14,7 @@ import { Plugin, AppUpdater, AppStatus, + WorkspaceAccessibility, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -140,6 +141,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { const { renderListApp } = await import('./application'); return mountWorkspaceApp(params, renderListApp); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); // create @@ -153,6 +155,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { const { renderCreatorApp } = await import('./application'); return mountWorkspaceApp(params, renderCreatorApp); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); // update diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 046ada41b11c..d2b9909eb682 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -8,7 +8,7 @@ import { getSelectedFeatureQuantities, isAppAccessibleInWorkspace, } from './utils'; -import { PublicAppInfo } from '../../../core/public'; +import { PublicAppInfo, WorkspaceAccessibility } from '../../../core/public'; import { AppNavLinkStatus } from '../../../core/public'; describe('workspace utils: featureMatchesConfig', () => { @@ -198,4 +198,18 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { ) ).toBe(true); }); + + it('An app is not accessible if its workspaceAccessibility is no', () => { + expect( + isAppAccessibleInWorkspace( + { + id: 'home', + title: 'Any app', + mount: jest.fn(), + workspaceAccessibility: WorkspaceAccessibility.NO, + }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(false); + }); }); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 327720be192e..e65d62a58396 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -10,6 +10,7 @@ import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES, WorkspaceObject, + WorkspaceAccessibility, } from '../../../core/public'; /** @@ -94,6 +95,13 @@ export const getSelectedFeatureQuantities = ( * Check if an app is accessible in a workspace based on the workspace configured features */ export function isAppAccessibleInWorkspace(app: App, workspace: WorkspaceObject) { + /** + * App is not accessible within workspace if it explicitly declare itself as workspaceAccessibility.No + */ + if (app.workspaceAccessibility === WorkspaceAccessibility.NO) { + return false; + } + /** * When workspace has no features configured, all apps are considered to be accessible */