Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Workspace]Optional workspaces params in repository #5162

Closed
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
53d84f2
[Workspace] Add workspaces parameters to all saved objects API
gaobinlong Sep 20, 2023
c841390
feat: update snapshot
SuZhou-Joe Sep 22, 2023
fb43df1
feat: optimize logic when checkConflict and bulkCreate (#189)
SuZhou-Joe Sep 23, 2023
7e18485
feat: call get when create with override
SuZhou-Joe Sep 23, 2023
aa69695
feat: update test according to count
SuZhou-Joe Sep 24, 2023
674bd09
feat: add integration test
SuZhou-Joe Sep 25, 2023
2fb66b7
fix: unit test
SuZhou-Joe Sep 25, 2023
952f13e
feat: regenerate ids when import
SuZhou-Joe Sep 25, 2023
23481a1
feat: add more unit test
SuZhou-Joe Sep 26, 2023
e740165
feat: minor changes logic on repository
SuZhou-Joe Sep 26, 2023
1997c73
feat: update unit test
SuZhou-Joe Sep 26, 2023
cae196e
feat: update test
SuZhou-Joe Sep 26, 2023
fd24685
feat: optimization according to comments
SuZhou-Joe Sep 28, 2023
ab92370
feat: update test
SuZhou-Joe Sep 28, 2023
c0cfd17
feat: optimize code
SuZhou-Joe Sep 28, 2023
f4b86f0
feat: add changelog
SuZhou-Joe Sep 30, 2023
8002f1c
Merge branch 'main' into feature/optional-workspaces-params-in-reposi…
joshuarrrr Oct 3, 2023
48f7ccf
Merge branch 'main' into feature/optional-workspaces-params-in-reposi…
SuZhou-Joe Oct 4, 2023
eb6d98f
feat: modify CHANGELOG
SuZhou-Joe Oct 4, 2023
2bf837a
feat: increase unit test coverage
SuZhou-Joe Oct 12, 2023
dbb1248
feat: add comment
SuZhou-Joe Oct 13, 2023
005d694
Merge branch 'main' into feature/optional-workspaces-params-in-reposi…
SuZhou-Joe Oct 17, 2023
0ba8df5
feat: remove useless generateId method
SuZhou-Joe Oct 17, 2023
743bf33
feat: remove flaky test
SuZhou-Joe Oct 17, 2023
285adad
feat: remove useless code
SuZhou-Joe Oct 17, 2023
142e9c0
fix: unit test
SuZhou-Joe Oct 18, 2023
e8aa3a4
feat: update snapshot
SuZhou-Joe Oct 18, 2023
3288961
feat: increase code coverage
SuZhou-Joe Oct 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Theme] Use themes' definitions to render the initial view ([#4936](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4936))
- [Theme] Make `next` theme the default ([#4854](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4854))
- [Discover] Update embeddable for saved searches ([#5081](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5081))
- [Workspace] Optional workspaces params in repository ([#5162](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5162))

### 🐛 Bug Fixes

1 change: 1 addition & 0 deletions src/core/public/saved_objects/saved_objects_client.ts
Original file line number Diff line number Diff line change
@@ -345,6 +345,7 @@ export class SavedObjectsClient {
filter: 'filter',
namespaces: 'namespaces',
preference: 'preference',
workspaces: 'workspaces',
};

const renamedQuery = renameKeys<SavedObjectsFindOptions, any>(renameMap, options);
Original file line number Diff line number Diff line change
@@ -857,4 +857,29 @@ describe('getSortedObjectsForExport()', () => {
`Can't specify both "search" and "objects" properties when exporting`
);
});

test('rejects when both types and objecys are passed in', () => {
const exportOpts = {
exportSizeLimit: 1,
savedObjectsClient,
objects: [{ type: 'index-pattern', id: '1' }],
types: ['foo'],
};

expect(exportSavedObjectsToStream(exportOpts)).rejects.toThrowErrorMatchingInlineSnapshot(
`Can't specify both "types" and "objects" properties when exporting`
);
});

test('rejects when bulkGet returns an error', () => {
const exportOpts = {
exportSizeLimit: 1,
savedObjectsClient,
objects: [{ type: 'index-pattern', id: '1' }],
};

expect(exportSavedObjectsToStream(exportOpts)).rejects.toThrowErrorMatchingInlineSnapshot(
`Can't specify both "types" and "objects" properties when exporting`
);
});
});
Original file line number Diff line number Diff line change
@@ -60,6 +60,8 @@ export interface SavedObjectsExportOptions {
excludeExportDetails?: boolean;
/** optional namespace to override the namespace used by the savedObjectsClient. */
namespace?: string;
/** optional workspaces to override the workspaces used by the savedObjectsClient. */
workspaces?: string[];
}

/**
@@ -87,13 +89,15 @@ async function fetchObjectsToExport({
exportSizeLimit,
savedObjectsClient,
namespace,
workspaces,
}: {
objects?: SavedObjectsExportOptions['objects'];
types?: string[];
search?: string;
exportSizeLimit: number;
savedObjectsClient: SavedObjectsClientContract;
namespace?: string;
workspaces?: string[];
}) {
if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) {
throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`);
@@ -105,7 +109,9 @@ async function fetchObjectsToExport({
if (typeof search === 'string') {
throw Boom.badRequest(`Can't specify both "search" and "objects" properties when exporting`);
}
const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace });
const bulkGetResult = await savedObjectsClient.bulkGet(objects, {
namespace,
});
const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error);
if (erroredObjects.length) {
const err = Boom.badRequest();
@@ -121,6 +127,7 @@ async function fetchObjectsToExport({
search,
perPage: exportSizeLimit,
namespaces: namespace ? [namespace] : undefined,
...(workspaces ? { workspaces } : {}),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we passing an object instead of an array?

Copy link
Member Author

@SuZhou-Joe SuZhou-Joe Oct 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workspaces is an array, in here if workspaces is null, ...({}) will have no impact on the params that passed to find, if we use workspaces: workspaces ? workspaces : undefined, there will be an extra { ...., workspaces: undefined } in find params.

});
if (findResponse.total > exportSizeLimit) {
throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`);
@@ -153,6 +160,7 @@ export async function exportSavedObjectsToStream({
includeReferencesDeep = false,
excludeExportDetails = false,
namespace,
workspaces,
}: SavedObjectsExportOptions) {
const rootObjects = await fetchObjectsToExport({
types,
@@ -161,6 +169,7 @@ export async function exportSavedObjectsToStream({
savedObjectsClient,
exportSizeLimit,
namespace,
workspaces,
});
let exportedObjects: Array<SavedObject<unknown>> = [];
let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = [];
3 changes: 3 additions & 0 deletions src/core/server/saved_objects/import/check_conflicts.ts
Original file line number Diff line number Diff line change
@@ -44,6 +44,7 @@ interface CheckConflictsParams {
ignoreRegularConflicts?: boolean;
retries?: SavedObjectsImportRetry[];
createNewCopies?: boolean;
workspaces?: string[];
}

const isUnresolvableConflict = (error: SavedObjectError) =>
@@ -56,6 +57,7 @@ export async function checkConflicts({
ignoreRegularConflicts,
retries = [],
createNewCopies,
workspaces,
}: CheckConflictsParams) {
const filteredObjects: Array<SavedObject<{ title?: string }>> = [];
const errors: SavedObjectsImportError[] = [];
@@ -77,6 +79,7 @@ export async function checkConflicts({
});
const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, {
namespace,
workspaces,
});
const errorMap = checkConflictsResult.errors.reduce(
(acc, { type, id, error }) => acc.set(`${type}:${id}`, error),
Original file line number Diff line number Diff line change
@@ -39,6 +39,7 @@ interface CreateSavedObjectsParams<T> {
importIdMap: Map<string, { id?: string; omitOriginId?: boolean }>;
namespace?: string;
overwrite?: boolean;
workspaces?: string[];
}
interface CreateSavedObjectsResult<T> {
createdObjects: Array<CreatedObject<T>>;
@@ -56,6 +57,7 @@ export const createSavedObjects = async <T>({
importIdMap,
namespace,
overwrite,
workspaces,
}: CreateSavedObjectsParams<T>): Promise<CreateSavedObjectsResult<T>> => {
// filter out any objects that resulted in errors
const errorSet = accumulatedErrors.reduce(
@@ -103,6 +105,7 @@ export const createSavedObjects = async <T>({
const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, {
namespace,
overwrite,
workspaces,
});
expectedResults = bulkCreateResponse.saved_objects;
}
Original file line number Diff line number Diff line change
@@ -42,7 +42,7 @@ import { typeRegistryMock } from '../saved_objects_type_registry.mock';
import { importSavedObjectsFromStream } from './import_saved_objects';

import { collectSavedObjects } from './collect_saved_objects';
import { regenerateIds } from './regenerate_ids';
import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids';
import { validateReferences } from './validate_references';
import { checkConflicts } from './check_conflicts';
import { checkOriginConflicts } from './check_origin_conflicts';
@@ -68,6 +68,7 @@ describe('#importSavedObjectsFromStream', () => {
importIdMap: new Map(),
});
getMockFn(regenerateIds).mockReturnValue(new Map());
getMockFn(regenerateIdsWithReference).mockReturnValue(Promise.resolve(new Map()));
getMockFn(validateReferences).mockResolvedValue([]);
getMockFn(checkConflicts).mockResolvedValue({
errors: [],
@@ -240,6 +241,15 @@ describe('#importSavedObjectsFromStream', () => {
]),
});
getMockFn(validateReferences).mockResolvedValue([errors[1]]);
getMockFn(regenerateIdsWithReference).mockResolvedValue(
Promise.resolve(
new Map([
['foo', {}],
['bar', {}],
['baz', {}],
])
)
);
getMockFn(checkConflicts).mockResolvedValue({
errors: [errors[2]],
filteredObjects,
@@ -268,6 +278,62 @@ describe('#importSavedObjectsFromStream', () => {
};
expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams);
});

test('creates saved objects when workspaces param is provided', async () => {
const options = setupOptions();
options.workspaces = ['foo'];
const collectedObjects = [createObject()];
const filteredObjects = [createObject()];
const errors = [createError(), createError(), createError(), createError()];
getMockFn(collectSavedObjects).mockResolvedValue({
errors: [errors[0]],
collectedObjects,
importIdMap: new Map([
['foo', {}],
['bar', {}],
['baz', {}],
]),
});
getMockFn(validateReferences).mockResolvedValue([errors[1]]);
getMockFn(regenerateIdsWithReference).mockResolvedValue(
Promise.resolve(
new Map([
['foo', {}],
['bar', {}],
['baz', {}],
])
)
);
getMockFn(checkConflicts).mockResolvedValue({
errors: [errors[2]],
filteredObjects,
importIdMap: new Map([['bar', { id: 'newId1' }]]),
pendingOverwrites: new Set(),
});
getMockFn(checkOriginConflicts).mockResolvedValue({
errors: [errors[3]],
importIdMap: new Map([['baz', { id: 'newId2' }]]),
pendingOverwrites: new Set(),
});

await importSavedObjectsFromStream(options);
const importIdMap = new Map([
['foo', {}],
['bar', { id: 'newId1' }],
['baz', { id: 'newId2' }],
]);
const createSavedObjectsParams = {
objects: collectedObjects,
accumulatedErrors: errors,
savedObjectsClient,
importIdMap,
overwrite,
namespace,
workspaces: options.workspaces,
};
expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams);
expect(regenerateIdsWithReference).toBeCalledTimes(1);
});
});

describe('with createNewCopies enabled', () => {
14 changes: 13 additions & 1 deletion src/core/server/saved_objects/import/import_saved_objects.ts
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ import { validateReferences } from './validate_references';
import { checkOriginConflicts } from './check_origin_conflicts';
import { createSavedObjects } from './create_saved_objects';
import { checkConflicts } from './check_conflicts';
import { regenerateIds } from './regenerate_ids';
import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids';

/**
* Import saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more
@@ -54,6 +54,7 @@ export async function importSavedObjectsFromStream({
savedObjectsClient,
typeRegistry,
namespace,
workspaces,
}: SavedObjectsImportOptions): Promise<SavedObjectsImportResponse> {
let errorAccumulator: SavedObjectsImportError[] = [];
const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name);
@@ -80,12 +81,22 @@ export async function importSavedObjectsFromStream({
if (createNewCopies) {
importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects);
} else {
if (workspaces) {
importIdMap = await regenerateIdsWithReference({
savedObjects: collectSavedObjectsResult.collectedObjects,
savedObjectsClient,
workspaces,
objectLimit,
importIdMap,
});
}
// Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces
const checkConflictsParams = {
objects: collectSavedObjectsResult.collectedObjects,
savedObjectsClient,
namespace,
ignoreRegularConflicts: overwrite,
workspaces,
};
const checkConflictsResult = await checkConflicts(checkConflictsParams);
errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors];
@@ -118,6 +129,7 @@ export async function importSavedObjectsFromStream({
importIdMap,
overwrite,
namespace,
...(workspaces ? { workspaces } : {}),
};
const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams);
errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors];
71 changes: 70 additions & 1 deletion src/core/server/saved_objects/import/regenerate_ids.test.ts
Original file line number Diff line number Diff line change
@@ -29,8 +29,10 @@
*/

import { mockUuidv4 } from './__mocks__';
import { regenerateIds } from './regenerate_ids';
import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids';
import { SavedObject } from '../types';
import { savedObjectsClientMock } from '../service/saved_objects_client.mock';
import { SavedObjectsBulkResponse } from '../service';

describe('#regenerateIds', () => {
const objects = ([
@@ -62,3 +64,70 @@ describe('#regenerateIds', () => {
`);
});
});

describe('#regenerateIdsWithReference', () => {
const objects = ([
{ type: 'foo', id: '1' },
{ type: 'bar', id: '2' },
{ type: 'baz', id: '3' },
] as any) as SavedObject[];

test('returns expected values', async () => {
const mockedSavedObjectsClient = savedObjectsClientMock.create();
mockUuidv4.mockReturnValueOnce('uuidv4 #1');
const result: SavedObjectsBulkResponse<unknown> = {
saved_objects: [
{
error: {
statusCode: 404,
error: '',
message: '',
},
id: '1',
type: 'foo',
attributes: {},
references: [],
},
{
id: '2',
type: 'bar',
attributes: {},
references: [],
workspaces: ['bar'],
},
{
id: '3',
type: 'baz',
attributes: {},
references: [],
workspaces: ['foo'],
},
],
};
mockedSavedObjectsClient.bulkGet.mockResolvedValue(result);
expect(
await regenerateIdsWithReference({
savedObjects: objects,
savedObjectsClient: mockedSavedObjectsClient,
workspaces: ['bar'],
objectLimit: 1000,
importIdMap: new Map(),
})
).toMatchInlineSnapshot(`
Map {
"foo:1" => Object {
"id": "1",
"omitOriginId": true,
},
"bar:2" => Object {
"id": "2",
"omitOriginId": false,
},
"baz:3" => Object {
"id": "uuidv4 #1",
"omitOriginId": true,
},
}
`);
});
});
35 changes: 34 additions & 1 deletion src/core/server/saved_objects/import/regenerate_ids.ts
Original file line number Diff line number Diff line change
@@ -29,7 +29,8 @@
*/

import { v4 as uuidv4 } from 'uuid';
import { SavedObject } from '../types';
import { SavedObject, SavedObjectsClientContract } from '../types';
import { SavedObjectsUtils } from '../service';

/**
* Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs.
@@ -42,3 +43,35 @@ export const regenerateIds = (objects: SavedObject[]) => {
}, new Map<string, { id: string; omitOriginId?: boolean }>());
return importIdMap;
};

export const regenerateIdsWithReference = async (props: {
SuZhou-Joe marked this conversation as resolved.
Show resolved Hide resolved
savedObjects: SavedObject[];
savedObjectsClient: SavedObjectsClientContract;
workspaces: string[];
objectLimit: number;
importIdMap: Map<string, { id?: string; omitOriginId?: boolean }>;
}): Promise<Map<string, { id?: string; omitOriginId?: boolean }>> => {
const { savedObjects, savedObjectsClient, workspaces, importIdMap } = props;

const bulkGetResult = await savedObjectsClient.bulkGet(
savedObjects.map((item) => ({ type: item.type, id: item.id }))
);

return bulkGetResult.saved_objects.reduce((acc, object) => {
if (object.error?.statusCode === 404) {
acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: true });
return acc;
}

const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces(
workspaces,
object.workspaces
);
if (filteredWorkspaces.length) {
acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true });
} else {
acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: false });
}
return acc;
}, importIdMap);
};
Original file line number Diff line number Diff line change
@@ -59,6 +59,7 @@ export async function resolveSavedObjectsImportErrors({
typeRegistry,
namespace,
createNewCopies,
workspaces,
}: SavedObjectsResolveImportErrorsOptions): Promise<SavedObjectsImportResponse> {
// throw a BadRequest error if we see invalid retries
validateRetries(retries);
@@ -157,6 +158,7 @@ export async function resolveSavedObjectsImportErrors({
importIdMap,
namespace,
overwrite,
workspaces,
};
const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects(
createSavedObjectsParams
4 changes: 4 additions & 0 deletions src/core/server/saved_objects/import/types.ts
Original file line number Diff line number Diff line change
@@ -187,6 +187,8 @@ export interface SavedObjectsImportOptions {
namespace?: string;
/** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */
createNewCopies: boolean;
/** if specified, will import in given workspaces, else will import as global object */
workspaces?: string[];
}

/**
@@ -208,6 +210,8 @@ export interface SavedObjectsResolveImportErrorsOptions {
namespace?: string;
/** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */
createNewCopies: boolean;
/** if specified, will import in given workspaces, else will import as global object */
workspaces?: string[];
}

export type CreatedObject<T> = SavedObject<T> & { destinationId?: string };

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -175,6 +175,9 @@ function defaultMapping(): IndexMapping {
},
},
},
workspaces: {
type: 'keyword',
},
},
};
}
Original file line number Diff line number Diff line change
@@ -82,6 +82,7 @@ describe('IndexMigrator', () => {
references: '7997cf5a56cc02bdc9c93361bde732b0',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
workspaces: '2f4316de49999235636386fe51dc06c1',
},
},
properties: {
@@ -92,6 +93,9 @@ describe('IndexMigrator', () => {
originId: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
workspaces: {
type: 'keyword',
},
references: {
type: 'nested',
properties: {
@@ -199,6 +203,7 @@ describe('IndexMigrator', () => {
references: '7997cf5a56cc02bdc9c93361bde732b0',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
workspaces: '2f4316de49999235636386fe51dc06c1',
},
},
properties: {
@@ -210,6 +215,9 @@ describe('IndexMigrator', () => {
originId: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
workspaces: {
type: 'keyword',
},
references: {
type: 'nested',
properties: {
@@ -260,6 +268,7 @@ describe('IndexMigrator', () => {
references: '7997cf5a56cc02bdc9c93361bde732b0',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
workspaces: '2f4316de49999235636386fe51dc06c1',
},
},
properties: {
@@ -271,6 +280,9 @@ describe('IndexMigrator', () => {
originId: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
workspaces: {
type: 'keyword',
},
references: {
type: 'nested',
properties: {

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion src/core/server/saved_objects/routes/bulk_create.ts
Original file line number Diff line number Diff line change
@@ -38,6 +38,9 @@
validate: {
query: schema.object({
overwrite: schema.boolean({ defaultValue: false }),
workspaces: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
),
}),
body: schema.arrayOf(
schema.object({
@@ -62,7 +65,14 @@
},
router.handleLegacyErrors(async (context, req, res) => {
const { overwrite } = req.query;
const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite });
let workspaces = req.query.workspaces;

Check warning on line 68 in src/core/server/saved_objects/routes/bulk_create.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/bulk_create.ts#L68

Added line #L68 was not covered by tests
if (typeof workspaces === 'string') {
workspaces = [workspaces];

Check warning on line 70 in src/core/server/saved_objects/routes/bulk_create.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/bulk_create.ts#L70

Added line #L70 was not covered by tests
}
const result = await context.core.savedObjects.client.bulkCreate(req.body, {

Check warning on line 72 in src/core/server/saved_objects/routes/bulk_create.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/bulk_create.ts#L72

Added line #L72 was not covered by tests
overwrite,
workspaces,
});
return res.ok({ body: result });
})
);
12 changes: 10 additions & 2 deletions src/core/server/saved_objects/routes/create.ts
Original file line number Diff line number Diff line change
@@ -56,15 +56,23 @@
)
),
initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
workspaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const { type, id } = req.params;
const { overwrite } = req.query;
const { attributes, migrationVersion, references, initialNamespaces } = req.body;
const { attributes, migrationVersion, references, initialNamespaces, workspaces } = req.body;

Check warning on line 66 in src/core/server/saved_objects/routes/create.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/create.ts#L66

Added line #L66 was not covered by tests

const options = { id, overwrite, migrationVersion, references, initialNamespaces };
const options = {

Check warning on line 68 in src/core/server/saved_objects/routes/create.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/create.ts#L68

Added line #L68 was not covered by tests
id,
overwrite,
migrationVersion,
references,
initialNamespaces,
workspaces,
};
const result = await context.core.savedObjects.client.create(type, attributes, options);
return res.ok({ body: result });
})
11 changes: 10 additions & 1 deletion src/core/server/saved_objects/routes/export.ts
Original file line number Diff line number Diff line change
@@ -57,12 +57,20 @@
search: schema.maybe(schema.string()),
includeReferencesDeep: schema.boolean({ defaultValue: false }),
excludeExportDetails: schema.boolean({ defaultValue: false }),
workspaces: schema.maybe(schema.arrayOf(schema.string())),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const savedObjectsClient = context.core.savedObjects.client;
const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body;
const {
type,
objects,
search,
excludeExportDetails,
includeReferencesDeep,
workspaces,
} = req.body;

Check warning on line 73 in src/core/server/saved_objects/routes/export.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/export.ts#L73

Added line #L73 was not covered by tests
const types = typeof type === 'string' ? [type] : type;

// need to access the registry for type validation, can't use the schema for this
@@ -98,6 +106,7 @@
exportSizeLimit: maxImportExportSize,
includeReferencesDeep,
excludeExportDetails,
workspaces,
});

const docsToExport: string[] = await createPromiseFromStreams([
8 changes: 8 additions & 0 deletions src/core/server/saved_objects/routes/find.ts
Original file line number Diff line number Diff line change
@@ -59,6 +59,9 @@
namespaces: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
),
workspaces: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
),
}),
},
},
@@ -67,6 +70,10 @@

const namespaces =
typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces;
let workspaces = req.query.workspaces;

Check warning on line 73 in src/core/server/saved_objects/routes/find.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/find.ts#L73

Added line #L73 was not covered by tests
if (typeof workspaces === 'string') {
workspaces = [workspaces];

Check warning on line 75 in src/core/server/saved_objects/routes/find.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/find.ts#L75

Added line #L75 was not covered by tests
}

const result = await context.core.savedObjects.client.find({
perPage: query.per_page,
@@ -81,6 +88,7 @@
fields: typeof query.fields === 'string' ? [query.fields] : query.fields,
filter: query.filter,
namespaces,
workspaces,
});

return res.ok({ body: result });
9 changes: 9 additions & 0 deletions src/core/server/saved_objects/routes/import.ts
Original file line number Diff line number Diff line change
@@ -60,6 +60,9 @@
{
overwrite: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: false }),
workspaces: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
),
},
{
validate: (object) => {
@@ -91,13 +94,19 @@
});
}

let workspaces = req.query.workspaces;

Check warning on line 97 in src/core/server/saved_objects/routes/import.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/import.ts#L97

Added line #L97 was not covered by tests
if (typeof workspaces === 'string') {
workspaces = [workspaces];

Check warning on line 99 in src/core/server/saved_objects/routes/import.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/import.ts#L99

Added line #L99 was not covered by tests
}

const result = await importSavedObjectsFromStream({
savedObjectsClient: context.core.savedObjects.client,
typeRegistry: context.core.savedObjects.typeRegistry,
readStream,
objectLimit: maxImportExportSize,
overwrite,
createNewCopies,
workspaces,
});

return res.ok({ body: result });
Original file line number Diff line number Diff line change
@@ -288,4 +288,38 @@ describe('GET /api/saved_objects/_find', () => {
defaultSearchOperator: 'OR',
});
});

it('accepts the query parameter workspaces as a string', async () => {
await supertest(httpSetup.server.listener)
.get('/api/saved_objects/_find?type=index-pattern&workspaces=foo')
.expect(200);

expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);

const options = savedObjectsClient.find.mock.calls[0][0];
expect(options).toEqual({
defaultSearchOperator: 'OR',
perPage: 20,
page: 1,
type: ['index-pattern'],
workspaces: ['foo'],
});
});

it('accepts the query parameter workspaces as an array', async () => {
await supertest(httpSetup.server.listener)
.get('/api/saved_objects/_find?type=index-pattern&workspaces=default&workspaces=foo')
.expect(200);

expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);

const options = savedObjectsClient.find.mock.calls[0][0];
expect(options).toEqual({
perPage: 20,
page: 1,
type: ['index-pattern'],
workspaces: ['default', 'foo'],
defaultSearchOperator: 'OR',
});
});
});
Original file line number Diff line number Diff line change
@@ -58,6 +58,9 @@
validate: {
query: schema.object({
createNewCopies: schema.boolean({ defaultValue: false }),
workspaces: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
),
}),
body: schema.object({
file: schema.stream(),
@@ -98,13 +101,19 @@
});
}

let workspaces = req.query.workspaces;

Check warning on line 104 in src/core/server/saved_objects/routes/resolve_import_errors.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/resolve_import_errors.ts#L104

Added line #L104 was not covered by tests
if (typeof workspaces === 'string') {
workspaces = [workspaces];

Check warning on line 106 in src/core/server/saved_objects/routes/resolve_import_errors.ts

Codecov / codecov/patch

src/core/server/saved_objects/routes/resolve_import_errors.ts#L106

Added line #L106 was not covered by tests
}

const result = await resolveSavedObjectsImportErrors({
typeRegistry: context.core.savedObjects.typeRegistry,
savedObjectsClient: context.core.savedObjects.client,
readStream,
retries: req.body.retries,
objectLimit: maxImportExportSize,
createNewCopies: req.query.createNewCopies,
workspaces,
});

return res.ok({ body: result });
5 changes: 4 additions & 1 deletion src/core/server/saved_objects/serialization/serializer.ts
Original file line number Diff line number Diff line change
@@ -73,7 +73,7 @@ export class SavedObjectsSerializer {
*/
public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc {
const { _id, _source, _seq_no, _primary_term } = doc;
const { type, namespace, namespaces, originId } = _source;
const { type, namespace, namespaces, originId, workspaces } = _source;

const version =
_seq_no != null || _primary_term != null
@@ -91,6 +91,7 @@ export class SavedObjectsSerializer {
...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }),
...(_source.updated_at && { updated_at: _source.updated_at }),
...(version && { version }),
...(workspaces && { workspaces }),
};
}

@@ -112,6 +113,7 @@ export class SavedObjectsSerializer {
updated_at,
version,
references,
workspaces,
} = savedObj;
const source = {
[type]: attributes,
@@ -122,6 +124,7 @@ export class SavedObjectsSerializer {
...(originId && { originId }),
...(migrationVersion && { migrationVersion }),
...(updated_at && { updated_at }),
...(workspaces && { workspaces }),
};

return {
2 changes: 2 additions & 0 deletions src/core/server/saved_objects/serialization/types.ts
Original file line number Diff line number Diff line change
@@ -52,6 +52,7 @@ export interface SavedObjectsRawDocSource {
updated_at?: string;
references?: SavedObjectReference[];
originId?: string;
workspaces?: string[];

[typeMapping: string]: any;
}
@@ -69,6 +70,7 @@ interface SavedObjectDoc<T = unknown> {
version?: string;
updated_at?: string;
originId?: string;
workspaces?: string[];
}

interface Referencable {
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/
import { SavedObject } from 'src/core/types';
import { isEqual } from 'lodash';
import * as osdTestServer from '../../../../../test_helpers/osd_server';
import { Readable } from 'stream';

const dashboard: Omit<SavedObject, 'id'> = {
type: 'dashboard',
attributes: {},
references: [],
};

describe('repository integration test', () => {
let root: ReturnType<typeof osdTestServer.createRoot>;
let opensearchServer: osdTestServer.TestOpenSearchUtils;
beforeAll(async () => {
const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({
adjustTimeout: (t: number) => jest.setTimeout(t),
});
opensearchServer = await startOpenSearch();
const startOSDResp = await startOpenSearchDashboards();
root = startOSDResp.root;
}, 30000);
afterAll(async () => {
await root.shutdown();
await opensearchServer.stop();
});

const deleteItem = async (object: Pick<SavedObject, 'id' | 'type'>) => {
expect(
[200, 404].includes(
(await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`))
.statusCode
)
);
};

const getItem = async (object: Pick<SavedObject, 'id' | 'type'>) => {
return await osdTestServer.request
.get(root, `/api/saved_objects/${object.type}/${object.id}`)
.expect(200);
};

const clearFooAndBar = async () => {
await deleteItem({
type: dashboard.type,
id: 'foo',
});
await deleteItem({
type: dashboard.type,
id: 'bar',
});
};

describe('workspace related CRUD', () => {
it('create', async () => {
const createResult = await osdTestServer.request
.post(root, `/api/saved_objects/${dashboard.type}`)
.send({
attributes: dashboard.attributes,
workspaces: ['foo'],
})
.expect(200);

expect(createResult.body.workspaces).toEqual(['foo']);
await deleteItem({
type: dashboard.type,
id: createResult.body.id,
});
});

it('create-with-override', async () => {
const createResult = await osdTestServer.request
.post(root, `/api/saved_objects/${dashboard.type}`)
.send({
attributes: dashboard.attributes,
workspaces: ['foo'],
})
.expect(200);

await osdTestServer.request
.post(root, `/api/saved_objects/${dashboard.type}/${createResult.body.id}?overwrite=true`)
.send({
attributes: dashboard.attributes,
workspaces: ['bar'],
})
.expect(409);

await deleteItem({
type: dashboard.type,
id: createResult.body.id,
});
});

it('bulk create', async () => {
await clearFooAndBar();
const createResultFoo = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=foo`)
.send([
{
...dashboard,
id: 'foo',
},
])
.expect(200);

const createResultBar = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=bar`)
.send([
{
...dashboard,
id: 'bar',
},
])
.expect(200);

expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual(
false
);
expect(
(createResultFoo.body.saved_objects as any[]).every((item) =>
isEqual(item.workspaces, ['foo'])
)
).toEqual(true);
expect((createResultBar.body.saved_objects as any[]).some((item) => item.error)).toEqual(
false
);
expect(
(createResultBar.body.saved_objects as any[]).every((item) =>
isEqual(item.workspaces, ['bar'])
)
).toEqual(true);
await Promise.all(
[...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) =>
deleteItem({
type: item.type,
id: item.id,
})
)
);
});

it('bulk create with conflict', async () => {
await clearFooAndBar();
const createResultFoo = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=foo`)
.send([
{
...dashboard,
id: 'foo',
},
])
.expect(200);

const createResultBar = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=bar`)
.send([
{
...dashboard,
id: 'bar',
},
])
.expect(200);

/**
* overwrite with workspaces
*/
const overwriteWithWorkspacesResult = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?overwrite=true&workspaces=foo`)
.send([
{
...dashboard,
id: 'bar',
},
{
...dashboard,
id: 'foo',
attributes: {
title: 'foo',
},
},
])
.expect(200);

expect(overwriteWithWorkspacesResult.body.saved_objects[0].error.statusCode).toEqual(409);
expect(overwriteWithWorkspacesResult.body.saved_objects[1].attributes.title).toEqual('foo');
expect(overwriteWithWorkspacesResult.body.saved_objects[1].workspaces).toEqual(['foo']);

await Promise.all(
[...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) =>
deleteItem({
type: item.type,
id: item.id,
})
)
);
});

it('checkConflicts when importing ndjson', async () => {
await clearFooAndBar();
const createResultFoo = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=foo`)
.send([
{
...dashboard,
id: 'foo',
},
])
.expect(200);

const createResultBar = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=bar`)
.send([
{
...dashboard,
id: 'bar',
},
])
.expect(200);

const getResultFoo = await getItem({
type: dashboard.type,
id: 'foo',
});
const getResultBar = await getItem({
type: dashboard.type,
id: 'bar',
});

const readableStream = new Readable();
readableStream.push(
`Content-Disposition: form-data; name="file"; filename="tmp.ndjson"\r\n\r\n`
);
readableStream.push(
[JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n')
);
readableStream.push(null);

/**
* import with workspaces when conflicts
*/
const importWithWorkspacesResult = await osdTestServer.request
.post(root, `/api/saved_objects/_import?workspaces=foo&overwrite=false`)
.attach(
'file',
Buffer.from(
[JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'),
'utf-8'
),
'tmp.ndjson'
)
.expect(200);

expect(importWithWorkspacesResult.body.success).toEqual(false);
expect(importWithWorkspacesResult.body.errors.length).toEqual(1);
expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo');
expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict');

await Promise.all(
[...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) =>
deleteItem({
type: item.type,
id: item.id,
})
)
);
});

it('find by workspaces', async () => {
const createResultFoo = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=foo`)
.send([
{
...dashboard,
id: 'foo',
},
])
.expect(200);

const createResultBar = await osdTestServer.request
.post(root, `/api/saved_objects/_bulk_create?workspaces=bar`)
.send([
{
...dashboard,
id: 'bar',
},
])
.expect(200);

const findResult = await osdTestServer.request
.get(root, `/api/saved_objects/_find?workspaces=bar&type=${dashboard.type}`)
.expect(200);

expect(findResult.body.total).toEqual(1);
expect(findResult.body.saved_objects[0].workspaces).toEqual(['bar']);

await Promise.all(
[...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) =>
deleteItem({
type: item.type,
id: item.id,
})
)
);
});
});
});
189 changes: 182 additions & 7 deletions src/core/server/saved_objects/service/lib/repository.test.js
Original file line number Diff line number Diff line change
@@ -27,7 +27,6 @@
* specific language governing permissions and limitations
* under the License.
*/

import { SavedObjectsRepository } from './repository';
import * as getSearchDslNS from './search_dsl/search_dsl';
import { SavedObjectsErrorHelpers } from './errors';
@@ -54,6 +53,12 @@ const createGenericNotFoundError = (...args) =>
const createUnsupportedTypeError = (...args) =>
SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload;

const omitWorkspace = (object) => {
const newObject = JSON.parse(JSON.stringify(object));
delete newObject.workspaces;
return newObject;
};

describe('SavedObjectsRepository', () => {
let client;
let savedObjectsRepository;
@@ -168,7 +173,7 @@ describe('SavedObjectsRepository', () => {
});

const getMockGetResponse = (
{ type, id, references, namespace: objectNamespace, originId },
{ type, id, references, namespace: objectNamespace, originId, workspaces },
namespace
) => {
const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace;
@@ -182,6 +187,7 @@ describe('SavedObjectsRepository', () => {
_source: {
...(registry.isSingleNamespace(type) && { namespace: namespaceId }),
...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }),
workspaces,
...(originId && { originId }),
type,
[type]: { title: 'Testing' },
@@ -444,6 +450,7 @@ describe('SavedObjectsRepository', () => {
references: [{ name: 'ref_0', type: 'test', id: '2' }],
};
const namespace = 'foo-namespace';
const workspace = 'foo-workspace';

const getMockBulkCreateResponse = (objects, namespace) => {
return {
@@ -480,7 +487,9 @@ describe('SavedObjectsRepository', () => {
opensearchClientMock.createSuccessTransportRequestPromise(response)
);
const result = await savedObjectsRepository.bulkCreate(objects, options);
expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0);
expect(client.mget).toHaveBeenCalledTimes(
multiNamespaceObjects?.length || options?.workspaces ? 1 : 0
);
return result;
};

@@ -683,6 +692,7 @@ describe('SavedObjectsRepository', () => {
expect.anything()
);
client.bulk.mockClear();
client.mget.mockClear();
};
await test(undefined);
await test(namespace);
@@ -730,6 +740,16 @@ describe('SavedObjectsRepository', () => {
await bulkCreateSuccess(objects, { namespace });
expectClientCallArgsAction(objects, { method: 'create', getId });
});

it(`adds workspaces to request body for any types`, async () => {
await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] });
const expected = expect.objectContaining({ workspaces: [workspace] });
const body = [expect.any(Object), expected, expect.any(Object), expected];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
});
});

describe('errors', () => {
@@ -877,6 +897,74 @@ describe('SavedObjectsRepository', () => {
const expectedError = expectErrorResult(obj3, { message: JSON.stringify(opensearchError) });
await bulkCreateError(obj3, opensearchError, expectedError);
});

it(`returns error when there is a conflict with an existing saved object according to workspaces`, async () => {
const obj = { ...obj3, workspaces: ['foo'] };
const response1 = {
status: 200,
docs: [
{
found: true,
_id: `${obj1.type}:${obj1.id}`,
_source: {
type: obj1.type,
workspaces: ['bar'],
},
},
{
found: true,
_id: `${obj.type}:${obj.id}`,
_source: {
type: obj.type,
workspaces: obj.workspaces,
},
},
{
found: true,
_id: `${obj2.type}:${obj2.id}`,
_source: {
type: obj2.type,
},
},
],
};
client.mget.mockResolvedValueOnce(
opensearchClientMock.createSuccessTransportRequestPromise(response1)
);
const response2 = getMockBulkCreateResponse([obj1, obj, obj2]);
client.bulk.mockResolvedValueOnce(
opensearchClientMock.createSuccessTransportRequestPromise(response2)
);

const options = { overwrite: true, workspaces: ['bar'] };
const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options);
expect(client.bulk).toHaveBeenCalled();
expect(client.mget).toHaveBeenCalled();

const body1 = {
docs: [
expect.objectContaining({ _id: `${obj1.type}:${obj1.id}` }),
expect.objectContaining({ _id: `${obj.type}:${obj.id}` }),
expect.objectContaining({ _id: `${obj2.type}:${obj2.id}` }),
],
};
expect(client.mget).toHaveBeenCalledWith(
expect.objectContaining({ body: body1 }),
expect.anything()
);
const body2 = [...expectObjArgs(obj1)];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body: body2 }),
expect.anything()
);
expect(result).toEqual({
saved_objects: [
expectSuccess(obj1),
expectErrorConflict(obj, { metadata: { isNotOverwritable: true } }),
expectErrorConflict(obj2, { metadata: { isNotOverwritable: true } }),
],
});
});
});

describe('migration', () => {
@@ -1171,6 +1259,12 @@ describe('SavedObjectsRepository', () => {
migrationVersion: doc._source.migrationVersion,
});

it(`returns early for undefined objects argument`, async () => {
const result = await savedObjectsRepository.bulkGet();
expect(result).toEqual({ saved_objects: [] });
expect(client.mget).not.toHaveBeenCalled();
});

it(`returns early for empty objects argument`, async () => {
const result = await bulkGet([]);
expect(result).toEqual({ saved_objects: [] });
@@ -1699,6 +1793,8 @@ describe('SavedObjectsRepository', () => {
const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' };
const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' };
const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' };
const obj8 = { type: 'dashboard', id: 'eight', workspaces: ['foo'] };
const obj9 = { type: 'dashboard', id: 'nine', workspaces: ['bar'] };
const namespace = 'foo-namespace';

const checkConflicts = async (objects, options) =>
@@ -1790,6 +1886,8 @@ describe('SavedObjectsRepository', () => {
{ found: false },
getMockGetResponse(obj6),
{ found: false },
getMockGetResponse(obj7),
getMockGetResponse(obj8),
],
};
client.mget.mockResolvedValue(
@@ -1818,6 +1916,36 @@ describe('SavedObjectsRepository', () => {
],
});
});

it(`expected results with workspaces`, async () => {
const objects = [obj8, obj9];
const response = {
status: 200,
docs: [getMockGetResponse(obj8), getMockGetResponse(obj9)],
};
client.mget.mockResolvedValue(
opensearchClientMock.createSuccessTransportRequestPromise(response)
);

const result = await checkConflicts(objects, {
workspaces: ['foo'],
});
expect(client.mget).toHaveBeenCalledTimes(1);
expect(result).toEqual({
errors: [
{ ...omitWorkspace(obj8), error: createConflictError(obj8.type, obj8.id) },
{
...omitWorkspace(obj9),
error: {
...createConflictError(obj9.type, obj9.id),
metadata: {
isNotOverwritable: true,
},
},
},
],
});
});
});
});

@@ -1846,9 +1974,17 @@ describe('SavedObjectsRepository', () => {

const createSuccess = async (type, attributes, options) => {
const result = await savedObjectsRepository.create(type, attributes, options);
expect(client.get).toHaveBeenCalledTimes(
registry.isMultiNamespace(type) && options.overwrite ? 1 : 0
);
let count = 0;
if (options?.overwrite && options.id && options.workspaces) {
/**
* workspace will call extra one to get latest status of current object
*/
count++;
}
if (registry.isMultiNamespace(type) && options.overwrite) {
count++;
}
expect(client.get).toHaveBeenCalledTimes(count);
return result;
};

@@ -2040,6 +2176,29 @@ describe('SavedObjectsRepository', () => {
expect.anything()
);
});

it(`doesn't modify workspaces when overwrite without target workspaces`, async () => {
const response = getMockGetResponse({ workspaces: ['foo'], id });
client.get.mockResolvedValueOnce(
opensearchClientMock.createSuccessTransportRequestPromise(response)
);

await savedObjectsRepository.create('dashboard', attributes, {
id,
overwrite: true,
workspaces: [],
});

expect(client.index).toHaveBeenCalledWith(
expect.objectContaining({
id: `dashboard:${id}`,
body: expect.objectContaining({
workspaces: ['foo'],
}),
}),
expect.anything()
);
});
});

describe('errors', () => {
@@ -2100,6 +2259,21 @@ describe('SavedObjectsRepository', () => {
expect(client.get).toHaveBeenCalled();
});

it(`throws error when there is a conflict with an existing workspaces saved object`, async () => {
const response = getMockGetResponse({ workspaces: ['foo'], id });
client.get.mockResolvedValueOnce(
opensearchClientMock.createSuccessTransportRequestPromise(response)
);
await expect(
savedObjectsRepository.create('dashboard', attributes, {
id,
overwrite: true,
workspaces: ['bar'],
})
).rejects.toThrowError(createConflictError('dashboard', id));
expect(client.get).toHaveBeenCalled();
});

it.todo(`throws when automatic index creation fails`);

it.todo(`throws when an unexpected failure occurs`);
@@ -2186,10 +2360,11 @@ describe('SavedObjectsRepository', () => {
const type = 'index-pattern';
const id = 'logstash-*';
const namespace = 'foo-namespace';
const workspaces = ['bar-workspace'];

const deleteSuccess = async (type, id, options) => {
if (registry.isMultiNamespace(type)) {
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace);
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace, workspaces);
client.get.mockResolvedValueOnce(
opensearchClientMock.createSuccessTransportRequestPromise(mockGetResponse)
);
123 changes: 112 additions & 11 deletions src/core/server/saved_objects/service/lib/repository.ts
Original file line number Diff line number Diff line change
@@ -243,6 +243,7 @@ export class SavedObjectsRepository {
originId,
initialNamespaces,
version,
workspaces,
} = options;
const namespace = normalizeNamespace(options.namespace);

@@ -279,6 +280,29 @@ export class SavedObjectsRepository {
}
}

let savedObjectWorkspaces = workspaces;

if (id && overwrite && workspaces) {
let currentItem;
try {
currentItem = await this.get(type, id);
} catch (e) {
// this.get will throw an error when no items can be found
}
if (currentItem) {
if (
SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces(
workspaces,
currentItem.workspaces
).length
) {
throw SavedObjectsErrorHelpers.createConflictError(type, id);
} else {
savedObjectWorkspaces = currentItem.workspaces;
}
}
}

const migrated = this._migrator.migrateDocument({
id,
type,
@@ -289,6 +313,7 @@ export class SavedObjectsRepository {
migrationVersion,
updated_at: time,
...(Array.isArray(references) && { references }),
...(Array.isArray(savedObjectWorkspaces) && { workspaces: savedObjectWorkspaces }),
});

const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc);
@@ -355,15 +380,28 @@ export class SavedObjectsRepository {

const method = object.id && overwrite ? 'index' : 'create';
const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type);
/**
* It requires a check when overwriting objects to target workspaces
*/
const requiresWorkspaceCheck = !!(object.id && options.workspaces);

if (object.id == null) object.id = uuid.v1();

let opensearchRequestIndexPayload = {};

if (requiresNamespacesCheck || requiresWorkspaceCheck) {
opensearchRequestIndexPayload = {
opensearchRequestIndex: bulkGetRequestIndexCounter,
};
bulkGetRequestIndexCounter++;
}

return {
tag: 'Right' as 'Right',
value: {
method,
object,
...(requiresNamespacesCheck && { opensearchRequestIndex: bulkGetRequestIndexCounter++ }),
...opensearchRequestIndexPayload,
},
};
});
@@ -374,7 +412,7 @@ export class SavedObjectsRepository {
.map(({ value: { object: { type, id } } }) => ({
_id: this._serializer.generateRawId(namespace, type, id),
_index: this.getIndexForType(type),
_source: ['type', 'namespaces'],
_source: ['type', 'namespaces', 'workspaces'],
}));
const bulkGetResponse = bulkGetDocs.length
? await this.client.mget(
@@ -405,7 +443,7 @@ export class SavedObjectsRepository {
if (opensearchRequestIndex !== undefined) {
const indexFound = bulkGetResponse?.statusCode !== 404;
const actualResult = indexFound
? bulkGetResponse?.body.docs[opensearchRequestIndex]
? bulkGetResponse?.body.docs?.[opensearchRequestIndex]
: undefined;
const docFound = indexFound && actualResult?.found === true;
// @ts-expect-error MultiGetHit._source is optional
@@ -438,6 +476,50 @@ export class SavedObjectsRepository {
versionProperties = getExpectedVersionProperties(version);
}

let savedObjectWorkspaces: string[] | undefined = options.workspaces;

if (expectedBulkGetResult.value.method !== 'create') {
const rawId = this._serializer.generateRawId(namespace, object.type, object.id);
const findObject =
bulkGetResponse?.statusCode !== 404
? bulkGetResponse?.body.docs?.find((item) => item._id === rawId)
: null;
/**
* When it is about to overwrite a object into options.workspace.
* We need to check if the options.workspaces is the subset of object.workspaces,
* Or it will be treated as a conflict
*/
if (findObject && findObject.found) {
const transformedObject = this._serializer.rawToSavedObject(
findObject as SavedObjectsRawDoc
) as SavedObject;
const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces(
options.workspaces,
transformedObject.workspaces
);
if (filteredWorkspaces.length) {
/**
* options.workspaces is not a subset of object.workspaces,
* return a conflict error.
*/
const { id, type } = object;
return {
tag: 'Left' as 'Left',
error: {
id,
type,
error: {
...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)),
metadata: { isNotOverwritable: true },
},
},
};
} else {
savedObjectWorkspaces = transformedObject.workspaces;
}
}
}

const expectedResult = {
opensearchRequestIndex: bulkRequestIndexCounter++,
requestedId: object.id,
@@ -452,6 +534,7 @@ export class SavedObjectsRepository {
updated_at: time,
references: object.references || [],
originId: object.originId,
workspaces: savedObjectWorkspaces,
}) as SavedObjectSanitizedDoc
),
};
@@ -549,7 +632,7 @@ export class SavedObjectsRepository {
const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({
_id: this._serializer.generateRawId(namespace, type, id),
_index: this.getIndexForType(type),
_source: ['type', 'namespaces'],
_source: ['type', 'namespaces', 'workspaces'],
}));
const bulkGetResponse = bulkGetDocs.length
? await this.client.mget(
@@ -572,13 +655,24 @@ export class SavedObjectsRepository {
const { type, id, opensearchRequestIndex } = expectedResult.value;
const doc = bulkGetResponse?.body.docs[opensearchRequestIndex];
if (doc?.found) {
let workspaceConflict = false;
if (options.workspaces) {
const transformedObject = this._serializer.rawToSavedObject(doc as SavedObjectsRawDoc);
const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces(
options.workspaces,
transformedObject.workspaces
);
if (filteredWorkspaces.length) {
workspaceConflict = true;
}
}
errors.push({
id,
type,
error: {
...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)),
// @ts-expect-error MultiGetHit._source is optional
...(!this.rawDocExistsInNamespace(doc!, namespace) && {
...((!this.rawDocExistsInNamespace(doc!, namespace) || workspaceConflict) && {
metadata: { isNotOverwritable: true },
}),
},
@@ -717,6 +811,7 @@ export class SavedObjectsRepository {
* @property {string} [options.namespace]
* @property {object} [options.hasReference] - { type, id }
* @property {string} [options.preference]
* @property {Array<string>} [options.workspaces]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
async find<T = unknown>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>> {
@@ -736,6 +831,7 @@ export class SavedObjectsRepository {
typeToNamespacesMap,
filter,
preference,
workspaces,
} = options;

if (!type && !typeToNamespacesMap) {
@@ -809,6 +905,7 @@ export class SavedObjectsRepository {
typeToNamespacesMap,
hasReference,
kueryNode,
workspaces,
}),
},
};
@@ -862,7 +959,7 @@ export class SavedObjectsRepository {
*/
async bulkGet<T = unknown>(
objects: SavedObjectsBulkGetObject[] = [],
options: SavedObjectsBaseOptions = {}
options: Omit<SavedObjectsBaseOptions, 'workspaces'> = {}
): Promise<SavedObjectsBulkResponse<T>> {
const namespace = normalizeNamespace(options.namespace);

@@ -950,7 +1047,7 @@ export class SavedObjectsRepository {
async get<T = unknown>(
type: string,
id: string,
options: SavedObjectsBaseOptions = {}
options: Omit<SavedObjectsBaseOptions, 'workspaces'> = {}
): Promise<SavedObject<T>> {
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
@@ -976,7 +1073,7 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}

const { originId, updated_at: updatedAt } = body._source;
const { originId, updated_at: updatedAt, workspaces } = body._source;

let namespaces: string[] = [];
if (!this._registry.isNamespaceAgnostic(type)) {
@@ -991,6 +1088,7 @@ export class SavedObjectsRepository {
namespaces,
...(originId && { originId }),
...(updatedAt && { updated_at: updatedAt }),
...(workspaces && { workspaces }),
version: encodeHitVersion(body),
attributes: body._source[type],
references: body._source.references || [],
@@ -1055,7 +1153,7 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}

const { originId } = body.get?._source ?? {};
const { originId, workspaces } = body.get?._source ?? {};
let namespaces: string[] = [];
if (!this._registry.isNamespaceAgnostic(type)) {
namespaces = body.get?._source.namespaces ?? [
@@ -1070,6 +1168,7 @@ export class SavedObjectsRepository {
version: encodeHitVersion(body),
namespaces,
...(originId && { originId }),
...(workspaces && { workspaces }),
references,
attributes,
};
@@ -1452,12 +1551,13 @@ export class SavedObjectsRepository {
};
}

const { originId } = get._source;
const { originId, workspaces } = get._source;
return {
id,
type,
...(namespaces && { namespaces }),
...(originId && { originId }),
...(workspaces && { workspaces }),
updated_at,
version: encodeVersion(seqNo, primaryTerm),
attributes,
@@ -1754,7 +1854,7 @@ function getSavedObjectFromSource<T>(
id: string,
doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource }
): SavedObject<T> {
const { originId, updated_at: updatedAt } = doc._source;
const { originId, updated_at: updatedAt, workspaces } = doc._source;

let namespaces: string[] = [];
if (!registry.isNamespaceAgnostic(type)) {
@@ -1769,6 +1869,7 @@ function getSavedObjectFromSource<T>(
namespaces,
...(originId && { originId }),
...(updatedAt && { updated_at: updatedAt }),
...(workspaces && { workspaces }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
references: doc._source.references || [],
Original file line number Diff line number Diff line change
@@ -625,6 +625,27 @@ describe('#getQueryParams', () => {
]);
});
});

describe('when using workspace search', () => {
it('using normal workspaces', () => {
const result: Result = getQueryParams({
registry,
workspaces: ['foo'],
});
expect(result.query.bool.filter[1]).toEqual({
bool: {
should: [
{
bool: {
must: [{ term: { workspaces: 'foo' } }],
},
},
],
minimum_should_match: 1,
},
});
});
});
});

describe('namespaces property', () => {
Original file line number Diff line number Diff line change
@@ -127,6 +127,16 @@ function getClauseForType(
},
};
}
/**
* Gets the clause that will filter for the workspace.
*/
function getClauseForWorkspace(workspace: string) {
return {
bool: {
must: [{ term: { workspaces: workspace } }],
},
};
}

interface HasReferenceQueryParams {
type: string;
@@ -144,6 +154,7 @@ interface QueryParams {
defaultSearchOperator?: string;
hasReference?: HasReferenceQueryParams;
kueryNode?: KueryNode;
workspaces?: string[];
}

export function getClauseForReference(reference: HasReferenceQueryParams) {
@@ -200,6 +211,7 @@ export function getQueryParams({
defaultSearchOperator,
hasReference,
kueryNode,
workspaces,
}: QueryParams) {
const types = getTypes(
registry,
@@ -224,6 +236,17 @@ export function getQueryParams({
],
};

if (workspaces?.filter((workspace) => workspace).length) {
bool.filter.push({
bool: {
should: workspaces
.filter((workspace) => workspace)
.map((workspace) => getClauseForWorkspace(workspace)),
minimum_should_match: 1,
},
});
}

if (search) {
const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search);
const simpleQueryStringClause = getSimpleQueryStringClause({
Original file line number Diff line number Diff line change
@@ -52,6 +52,7 @@ interface GetSearchDslOptions {
id: string;
};
kueryNode?: KueryNode;
workspaces?: string[];
}

export function getSearchDsl(
@@ -71,6 +72,7 @@ export function getSearchDsl(
typeToNamespacesMap,
hasReference,
kueryNode,
workspaces,
} = options;

if (!type) {
@@ -93,6 +95,7 @@ export function getSearchDsl(
defaultSearchOperator,
hasReference,
kueryNode,
workspaces,
}),
...getSortingParams(mappings, type, sortField, sortOrder),
};
7 changes: 7 additions & 0 deletions src/core/server/saved_objects/service/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -80,4 +80,11 @@ export class SavedObjectsUtils {
total: 0,
saved_objects: [],
});

public static filterWorkspacesAccordingToBaseWorkspaces(
targetWorkspaces?: string[],
sourceWorkspaces?: string[]
): string[] {
return targetWorkspaces?.filter((item) => !sourceWorkspaces?.includes(item)) || [];
}
}
Original file line number Diff line number Diff line change
@@ -363,7 +363,7 @@ export class SavedObjectsClient {
*/
async bulkGet<T = unknown>(
objects: SavedObjectsBulkGetObject[] = [],
options: SavedObjectsBaseOptions = {}
options: Omit<SavedObjectsBaseOptions, 'workspaces'> = {}
): Promise<SavedObjectsBulkResponse<T>> {
return await this._repository.bulkGet(objects, options);
}
4 changes: 4 additions & 0 deletions src/core/server/saved_objects/types.ts
Original file line number Diff line number Diff line change
@@ -110,6 +110,8 @@ export interface SavedObjectsFindOptions {
typeToNamespacesMap?: Map<string, string[] | undefined>;
/** An optional OpenSearch preference value to be used for the query **/
preference?: string;
/** If specified, will find all objects belong to specified workspaces **/
workspaces?: string[];
}

/**
@@ -119,6 +121,8 @@ export interface SavedObjectsFindOptions {
export interface SavedObjectsBaseOptions {
/** Specify the namespace for this operation */
namespace?: string;
/** Specify the workspaces for this operation */
workspaces?: string[];
}

/**
2 changes: 2 additions & 0 deletions src/core/types/saved_objects.ts
Original file line number Diff line number Diff line change
@@ -113,6 +113,8 @@ export interface SavedObject<T = unknown> {
* space.
*/
originId?: string;
/** Workspaces that this saved object exists in. */
workspaces?: string[];
}

export interface SavedObjectError {