From 9304dc6ee3d36284798b8aad93e9a08e9a1f679b Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Wed, 26 Sep 2018 08:29:48 -0700 Subject: [PATCH] Saved Object Namespaces (#23378) * Use an instance of SavedObjectsSerializer for migrations and the repository * Fixing spelling of serialization * Making the serializer conditionally include and prepend id with ns * Adding repository tests for the namespaces * Implementing find * Modifying the SOCs to pass the options with the namespace * Centralizing omitting the namespace when using serializer.rawToSavedObject * Passing the schema through to the SavedObjectRepositoryProvider * Changing the schema to work with undefined ui exports schemas * Adding schema tests * Making the complimentary serialization test use the namespace * Fixing uiExports * Fixing some tests * Fixing included fields for the find * Fixing include field tests, they're checking length also... * Updating Repository test after adding namespace to always included fields * Renaming UIExportsSavedObjectTypeSchema to SavedObjectsSchemaDefinition * Completing rename... forgot to save usages * Fixing issue with the serialization.isRawSavedObject and the trailing : --- .../build_active_mappings.test.ts.snap | 3 + .../migrations/core/build_active_mappings.ts | 3 + .../migrations/core/index_migrator.test.ts | 7 +- .../migrations/core/index_migrator.ts | 8 +- .../migrations/core/migrate_raw_docs.test.ts | 7 +- .../migrations/core/migrate_raw_docs.ts | 14 +- .../migrations/core/migration_context.ts | 4 + .../kibana_migrator.test.ts.snap | 3 + .../migrations/kibana/kibana_migrator.test.ts | 1 + .../migrations/kibana/kibana_migrator.ts | 9 +- .../saved_objects/saved_objects_mixin.js | 6 +- src/server/saved_objects/schema/index.ts | 20 + .../saved_objects/schema/schema.test.ts | 48 + src/server/saved_objects/schema/schema.ts | 47 + .../saved_objects/serialization/index.ts | 157 +-- .../serialization/serialization.test.ts | 957 ++++++++++++++++++ .../serialization/serializtion.test.ts | 264 ----- .../service/create_saved_objects_service.js | 4 +- .../service/lib/included_fields.js | 1 + .../service/lib/included_fields.test.js | 16 +- .../saved_objects/service/lib/repository.js | 79 +- .../service/lib/repository.test.js | 489 ++++++++- .../service/lib/repository_provider.js | 6 + .../service/lib/repository_provider.test.js | 57 +- .../service/lib/search_dsl/query_params.js | 58 +- .../lib/search_dsl/query_params.test.js | 521 ++++++++-- .../service/lib/search_dsl/search_dsl.js | 7 +- .../service/lib/search_dsl/search_dsl.test.js | 17 +- .../service/lib/search_dsl/sorting_params.js | 33 +- .../lib/search_dsl/sorting_params.test.js | 15 + .../service/saved_objects_client.js | 22 +- .../service/saved_objects_client.test.js | 45 +- .../__tests__/collect_ui_exports.js | 23 +- src/ui/ui_exports/ui_export_types/index.js | 1 + .../ui_export_types/saved_object.js | 2 + .../apis/saved_objects/migrations.js | 3 + .../secure_saved_objects_client.js | 18 +- .../secure_saved_objects_client.test.js | 47 +- 38 files changed, 2442 insertions(+), 580 deletions(-) create mode 100644 src/server/saved_objects/schema/index.ts create mode 100644 src/server/saved_objects/schema/schema.test.ts create mode 100644 src/server/saved_objects/schema/schema.ts create mode 100644 src/server/saved_objects/serialization/serialization.test.ts delete mode 100644 src/server/saved_objects/serialization/serializtion.test.ts diff --git a/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index ebb9566181eb4..d16f9feecb9c0 100644 --- a/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -23,6 +23,9 @@ Object { "dynamic": "true", "type": "object", }, + "namespace": Object { + "type": "keyword", + }, "type": Object { "type": "keyword", }, diff --git a/src/server/saved_objects/migrations/core/build_active_mappings.ts b/src/server/saved_objects/migrations/core/build_active_mappings.ts index 5181e99e90e67..f7f4ef73a5c71 100644 --- a/src/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/server/saved_objects/migrations/core/build_active_mappings.ts @@ -71,6 +71,9 @@ function defaultMapping(): IndexMapping { type: { type: 'keyword', }, + namespace: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/src/server/saved_objects/migrations/core/index_migrator.test.ts b/src/server/saved_objects/migrations/core/index_migrator.test.ts index 6d08e703dcfdf..d14c8f55c9b92 100644 --- a/src/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/server/saved_objects/migrations/core/index_migrator.test.ts @@ -19,7 +19,8 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { ROOT_TYPE, SavedObjectDoc } from '../../serialization'; +import { SavedObjectsSchema } from '../../schema'; +import { ROOT_TYPE, SavedObjectDoc, SavedObjectsSerializer } from '../../serialization'; import { CallCluster } from './call_cluster'; import { IndexMigrator } from './index_migrator'; @@ -46,6 +47,7 @@ describe('IndexMigrator', () => { }, foo: { type: 'text' }, migrationVersion: { dynamic: 'true', type: 'object' }, + namespace: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, }, @@ -78,6 +80,7 @@ describe('IndexMigrator', () => { }, foo: { type: 'long' }, migrationVersion: { dynamic: 'true', type: 'object' }, + namespace: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, }, @@ -188,6 +191,7 @@ describe('IndexMigrator', () => { }, foo: { type: 'text' }, migrationVersion: { dynamic: 'true', type: 'object' }, + namespace: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, }, @@ -301,6 +305,7 @@ function defaultOpts() { migrationVersion: {}, migrate: _.identity, }, + serializer: new SavedObjectsSerializer(new SavedObjectsSchema()), }; } diff --git a/src/server/saved_objects/migrations/core/index_migrator.ts b/src/server/saved_objects/migrations/core/index_migrator.ts index d1b718c1be390..bae5d37bc6e3a 100644 --- a/src/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/server/saved_objects/migrations/core/index_migrator.ts @@ -149,7 +149,7 @@ async function migrateIndex(context: Context): Promise { */ async function migrateSourceToDest(context: Context) { const { callCluster, alias, dest, source, batchSize } = context; - const { scrollDuration, documentMigrator, log } = context; + const { scrollDuration, documentMigrator, log, serializer } = context; if (!source.exists) { return; @@ -174,6 +174,10 @@ async function migrateSourceToDest(context: Context) { log.debug(`Migrating saved objects ${docs.map(d => d._id).join(', ')}`); - await Index.write(callCluster, dest.indexName, migrateRawDocs(documentMigrator.migrate, docs)); + await Index.write( + callCluster, + dest.indexName, + migrateRawDocs(serializer, documentMigrator.migrate, docs) + ); } } diff --git a/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 3b8088af8bf27..a4906b9c7ec85 100644 --- a/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -19,13 +19,14 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { ROOT_TYPE } from '../../serialization'; +import { SavedObjectsSchema } from '../../schema'; +import { ROOT_TYPE, SavedObjectsSerializer } from '../../serialization'; import { migrateRawDocs } from './migrate_raw_docs'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { const transform = sinon.spy((doc: any) => _.set(doc, 'attributes.name', 'HOI!')); - const result = migrateRawDocs(transform, [ + const result = migrateRawDocs(new SavedObjectsSerializer(new SavedObjectsSchema()), transform, [ { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, ]); @@ -48,7 +49,7 @@ describe('migrateRawDocs', () => { test('passes invalid docs through untouched', async () => { const transform = sinon.spy((doc: any) => _.set(_.cloneDeep(doc), 'attributes.name', 'TADA')); - const result = migrateRawDocs(transform, [ + const result = migrateRawDocs(new SavedObjectsSerializer(new SavedObjectsSchema()), transform, [ { _id: 'foo:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, ]); diff --git a/src/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/server/saved_objects/migrations/core/migrate_raw_docs.ts index 3f37606fa0c29..ffafa9908d23e 100644 --- a/src/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -21,7 +21,7 @@ * This file provides logic for migrating raw documents. */ -import { isRawSavedObject, RawDoc, rawToSavedObject, savedObjectToRaw } from '../../serialization'; +import { RawDoc, SavedObjectsSerializer } from '../../serialization'; import { TransformFn } from './document_migrator'; /** @@ -32,12 +32,16 @@ import { TransformFn } from './document_migrator'; * @param {RawDoc[]} rawDocs * @returns {RawDoc[]} */ -export function migrateRawDocs(migrateDoc: TransformFn, rawDocs: RawDoc[]): RawDoc[] { +export function migrateRawDocs( + serializer: SavedObjectsSerializer, + migrateDoc: TransformFn, + rawDocs: RawDoc[] +): RawDoc[] { return rawDocs.map(raw => { - if (isRawSavedObject(raw)) { - const savedObject = rawToSavedObject(raw); + if (serializer.isRawSavedObject(raw)) { + const savedObject = serializer.rawToSavedObject(raw); savedObject.migrationVersion = savedObject.migrationVersion || {}; - return savedObjectToRaw(migrateDoc(savedObject)); + return serializer.savedObjectToRaw(migrateDoc(savedObject)); } return raw; diff --git a/src/server/saved_objects/migrations/core/migration_context.ts b/src/server/saved_objects/migrations/core/migration_context.ts index 78471bc84f471..e660a53ef8fcf 100644 --- a/src/server/saved_objects/migrations/core/migration_context.ts +++ b/src/server/saved_objects/migrations/core/migration_context.ts @@ -24,6 +24,7 @@ * serves as a central blueprint for what migrations will end up doing. */ +import { SavedObjectsSerializer } from '../../serialization'; import { buildActiveMappings } from './build_active_mappings'; import { CallCluster, MappingProperties } from './call_cluster'; import { VersionedTransformer } from './document_migrator'; @@ -39,6 +40,7 @@ export interface MigrationOpts { log: LogFn; mappingProperties: MappingProperties; documentMigrator: VersionedTransformer; + serializer: SavedObjectsSerializer; } export interface Context { @@ -51,6 +53,7 @@ export interface Context { batchSize: number; pollInterval: number; scrollDuration: string; + serializer: SavedObjectsSerializer; } /** @@ -74,6 +77,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { documentMigrator: opts.documentMigrator, pollInterval: opts.pollInterval, scrollDuration: opts.scrollDuration, + serializer: opts.serializer, }; } diff --git a/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 37505db9b3adc..4c329c928753a 100644 --- a/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -23,6 +23,9 @@ Object { "dynamic": "true", "type": "object", }, + "namespace": Object { + "type": "keyword", + }, "type": Object { "type": "keyword", }, diff --git a/src/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index fb8218345575e..590873d58a3fd 100644 --- a/src/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -88,6 +88,7 @@ function mockKbnServer({ configValues }: { configValues?: any } = {}) { savedObjectValidations: {}, savedObjectMigrations: {}, savedObjectMappings: [], + savedObjectSchemas: {}, }, server: { config: () => ({ diff --git a/src/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/server/saved_objects/migrations/kibana/kibana_migrator.ts index 59a46624575a9..56adb23f4aa6d 100644 --- a/src/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -22,7 +22,8 @@ * (the shape of the mappings and documents in the index). */ -import { SavedObjectDoc } from '../../serialization'; +import { SavedObjectsSchema, SavedObjectsSchemaDefinition } from '../../schema'; +import { SavedObjectDoc, SavedObjectsSerializer } from '../../serialization'; import { docValidator } from '../../validation'; import { buildActiveMappings, CallCluster, IndexMigrator, LogFn, MappingProperties } from '../core'; import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator'; @@ -34,6 +35,7 @@ export interface KbnServer { savedObjectMappings: any[]; savedObjectMigrations: any; savedObjectValidations: any; + savedObjectSchemas: SavedObjectsSchemaDefinition; }; } @@ -64,6 +66,7 @@ export class KibanaMigrator { private documentMigrator: VersionedTransformer; private mappingProperties: MappingProperties; private log: LogFn; + private serializer: SavedObjectsSerializer; /** * Creates an instance of KibanaMigrator. @@ -74,6 +77,9 @@ export class KibanaMigrator { */ constructor({ kbnServer }: { kbnServer: KbnServer }) { this.kbnServer = kbnServer; + this.serializer = new SavedObjectsSerializer( + new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas) + ); this.mappingProperties = mergeProperties(kbnServer.uiExports.savedObjectMappings || []); this.log = (meta: string[], message: string) => kbnServer.server.log(meta, message); this.documentMigrator = new DocumentMigrator({ @@ -133,6 +139,7 @@ export class KibanaMigrator { mappingProperties: this.mappingProperties, pollInterval: config.get('migrations.pollInterval'), scrollDuration: config.get('migrations.scrollDuration'), + serializer: this.serializer, }); return migrator.migrate(); diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index a222b1c49180c..a487dc8e041cd 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -19,6 +19,8 @@ import { createSavedObjectsService } from './service'; import { KibanaMigrator } from './migrations'; +import { SavedObjectsSchema } from './schema'; +import { SavedObjectsSerializer } from './serialization'; import { createBulkCreateRoute, @@ -62,7 +64,9 @@ export function savedObjectsMixin(kbnServer, server) { server.route(createGetRoute(prereqs)); server.route(createUpdateRoute(prereqs)); - server.decorate('server', 'savedObjects', createSavedObjectsService(server, migrator)); + const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas); + const serializer = new SavedObjectsSerializer(schema); + server.decorate('server', 'savedObjects', createSavedObjectsService(server, schema, serializer, migrator)); const savedObjectsClientCache = new WeakMap(); server.decorate('request', 'getSavedObjectsClient', function () { diff --git a/src/server/saved_objects/schema/index.ts b/src/server/saved_objects/schema/index.ts new file mode 100644 index 0000000000000..d30bbb8d34cd3 --- /dev/null +++ b/src/server/saved_objects/schema/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SavedObjectsSchema, SavedObjectsSchemaDefinition } from './schema'; diff --git a/src/server/saved_objects/schema/schema.test.ts b/src/server/saved_objects/schema/schema.test.ts new file mode 100644 index 0000000000000..43cf27fbae790 --- /dev/null +++ b/src/server/saved_objects/schema/schema.test.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsSchema } from './schema'; + +describe('#isNamespaceAgnostic', () => { + it(`returns false for unknown types`, () => { + const schema = new SavedObjectsSchema(); + const result = schema.isNamespaceAgnostic('bar'); + expect(result).toBe(false); + }); + + it(`returns true for explicitly namespace agnostic type`, () => { + const schema = new SavedObjectsSchema({ + foo: { + isNamespaceAgnostic: true, + }, + }); + const result = schema.isNamespaceAgnostic('foo'); + expect(result).toBe(true); + }); + + it(`returns false for explicitly namespaced type`, () => { + const schema = new SavedObjectsSchema({ + foo: { + isNamespaceAgnostic: false, + }, + }); + const result = schema.isNamespaceAgnostic('foo'); + expect(result).toBe(false); + }); +}); diff --git a/src/server/saved_objects/schema/schema.ts b/src/server/saved_objects/schema/schema.ts new file mode 100644 index 0000000000000..09fceb9adb440 --- /dev/null +++ b/src/server/saved_objects/schema/schema.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +interface SavedObjectsSchemaTypeDefinition { + isNamespaceAgnostic: boolean; +} + +export interface SavedObjectsSchemaDefinition { + [key: string]: SavedObjectsSchemaTypeDefinition; +} + +export class SavedObjectsSchema { + private readonly definition?: SavedObjectsSchemaDefinition; + constructor(schemaDefinition?: SavedObjectsSchemaDefinition) { + this.definition = schemaDefinition; + } + + public isNamespaceAgnostic(type: string) { + // if no plugins have registered a uiExports.savedObjectSchemas, + // this.schema will be undefined, and no types are namespace agnostic + if (!this.definition) { + return false; + } + + const typeSchema = this.definition[type]; + if (!typeSchema) { + return false; + } + return Boolean(typeSchema.isNamespaceAgnostic); + } +} diff --git a/src/server/saved_objects/serialization/index.ts b/src/server/saved_objects/serialization/index.ts index c1a046ba8cef7..46b869cb28a85 100644 --- a/src/server/saved_objects/serialization/index.ts +++ b/src/server/saved_objects/serialization/index.ts @@ -23,6 +23,7 @@ */ import uuid from 'uuid'; +import { SavedObjectsSchema } from '../schema'; /** * The root document type. In 7.0, this needs to change to '_doc'. @@ -57,88 +58,108 @@ export interface SavedObjectDoc { attributes: object; id: string; type: string; + namespace?: string; migrationVersion?: MigrationVersion; version?: number; + updated_at?: Date; [rootProp: string]: any; } -/** - * Determines whether or not the raw document can be converted to a saved object. - * - * @param {RawDoc} rawDoc - The raw ES document to be tested - */ -export function isRawSavedObject(rawDoc: RawDoc) { - const { type } = rawDoc._source; - return type && rawDoc._id.startsWith(type) && rawDoc._source.hasOwnProperty(type); -} - -/** - * Converts a document from the format that is stored in elasticsearch to the saved object client format. - * - * @param {RawDoc} rawDoc - The raw ES document to be converted to saved object format. - */ -export function rawToSavedObject({ _id, _source, _version }: RawDoc): SavedObjectDoc { - const { type } = _source; - return { - type, - id: trimIdPrefix(type, _id), - attributes: _source[type], - ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), - ...(_source.updated_at && { updated_at: _source.updated_at }), - ...(_version != null && { version: _version }), - }; -} - -/** - * Converts a document from the saved object client format to the format that is stored in elasticsearch. - * - * @param {SavedObjectDoc} savedObj - The saved object to be converted to raw ES format. - */ -export function savedObjectToRaw(savedObj: SavedObjectDoc): RawDoc { - const { id, type, attributes, version } = savedObj; - const source = { - ...savedObj, - [type]: attributes, - }; - - delete source.id; - delete source.attributes; - delete source.version; - - return { - _id: generateRawId(type, id), - _source: source, - _type: ROOT_TYPE, - ...(version != null && { _version: version }), - }; -} - -/** - * Given a saved object type and id, generates the compound id that is stored in the raw document. - * - * @param {string} type - The saved object type - * @param {string} id - The id of the saved object - */ -export function generateRawId(type: string, id?: string) { - return `${type}:${id || uuid.v1()}`; -} - function assertNonEmptyString(value: string, name: string) { if (!value || typeof value !== 'string') { throw new TypeError(`Expected "${value}" to be a ${name}`); } } -function trimIdPrefix(type: string, id: string) { - assertNonEmptyString(id, 'document id'); - assertNonEmptyString(type, 'saved object type'); +export class SavedObjectsSerializer { + private readonly schema: SavedObjectsSchema; - const prefix = `${type}:`; + constructor(schema: SavedObjectsSchema) { + this.schema = schema; + } + /** + * Determines whether or not the raw document can be converted to a saved object. + * + * @param {RawDoc} rawDoc - The raw ES document to be tested + */ + public isRawSavedObject(rawDoc: RawDoc) { + const { type, namespace } = rawDoc._source; + const namespacePrefix = + namespace && !this.schema.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + return ( + type && + rawDoc._id.startsWith(`${namespacePrefix}${type}:`) && + rawDoc._source.hasOwnProperty(type) + ); + } - if (!id.startsWith(prefix)) { - return id; + /** + * Converts a document from the format that is stored in elasticsearch to the saved object client format. + * + * @param {RawDoc} rawDoc - The raw ES document to be converted to saved object format. + */ + public rawToSavedObject({ _id, _source, _version }: RawDoc): SavedObjectDoc { + const { type, namespace } = _source; + return { + type, + id: this.trimIdPrefix(namespace, type, _id), + ...(namespace && !this.schema.isNamespaceAgnostic(type) && { namespace }), + attributes: _source[type], + ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), + ...(_source.updated_at && { updated_at: _source.updated_at }), + ...(_version != null && { version: _version }), + }; } - return id.slice(prefix.length); + /** + * Converts a document from the saved object client format to the format that is stored in elasticsearch. + * + * @param {SavedObjectDoc} savedObj - The saved object to be converted to raw ES format. + */ + public savedObjectToRaw(savedObj: SavedObjectDoc): RawDoc { + const { id, type, namespace, attributes, migrationVersion, updated_at, version } = savedObj; + const source = { + [type]: attributes, + type, + ...(namespace && !this.schema.isNamespaceAgnostic(type) && { namespace }), + ...(migrationVersion && { migrationVersion }), + ...(updated_at && { updated_at }), + }; + + return { + _id: this.generateRawId(namespace, type, id), + _source: source, + _type: ROOT_TYPE, + ...(version != null && { _version: version }), + }; + } + + /** + * Given a saved object type and id, generates the compound id that is stored in the raw document. + * + * @param {string} namespace - The namespace of the saved object + * @param {string} type - The saved object type + * @param {string} id - The id of the saved object + */ + public generateRawId(namespace: string | undefined, type: string, id?: string) { + const namespacePrefix = + namespace && !this.schema.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + return `${namespacePrefix}${type}:${id || uuid.v1()}`; + } + + private trimIdPrefix(namespace: string | undefined, type: string, id: string) { + assertNonEmptyString(id, 'document id'); + assertNonEmptyString(type, 'saved object type'); + + const namespacePrefix = + namespace && !this.schema.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + const prefix = `${namespacePrefix}${type}:`; + + if (!id.startsWith(prefix)) { + return id; + } + + return id.slice(prefix.length); + } } diff --git a/src/server/saved_objects/serialization/serialization.test.ts b/src/server/saved_objects/serialization/serialization.test.ts new file mode 100644 index 0000000000000..686acf8521e36 --- /dev/null +++ b/src/server/saved_objects/serialization/serialization.test.ts @@ -0,0 +1,957 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { ROOT_TYPE, SavedObjectsSerializer } from '.'; +import { SavedObjectsSchema } from '../schema'; + +describe('saved object conversion', () => { + describe('#rawToSavedObject', () => { + test('it copies the _source.type property to type', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).toHaveProperty('type', 'foo'); + }); + + test('if specified it copies the _source.migrationVersion property to migrationVersion', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', + }, + }, + }); + expect(actual).toHaveProperty('migrationVersion', { + hello: '1.2.3', + acl: '33.3.5', + }); + }); + + test(`if _source.migrationVersion is unspecified it doesn't set migrationVersion`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('migrationVersion'); + }); + + test('it converts the id and type properties, and retains migrationVersion', () => { + const now = new Date(); + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'hello:world', + _type: ROOT_TYPE, + _version: 3, + _source: { + type: 'hello', + hello: { + a: 'b', + c: 'd', + }, + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', + }, + updated_at: now, + }, + }); + const expected = { + id: 'world', + type: 'hello', + version: 3, + attributes: { + a: 'b', + c: 'd', + }, + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', + }, + updated_at: now, + }; + expect(expected).toEqual(actual); + }); + + test(`if version is unspecified it doesn't set version`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + hello: {}, + }, + }); + expect(actual).not.toHaveProperty('version'); + }); + + test(`if specified it copies _version to version`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _version: 4, + _source: { + type: 'foo', + hello: {}, + }, + }); + expect(actual).toHaveProperty('version', 4); + }); + + test('if specified it copies the _source.updated_at property to updated_at', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const now = Date(); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + updated_at: now, + }, + }); + expect(actual).toHaveProperty('updated_at', now); + }); + + test(`if _source.updated_at is unspecified it doesn't set updated_at`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('updated_at'); + }); + + test('it does not pass unknown properties through', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'universe', + _type: ROOT_TYPE, + _source: { + type: 'hello', + hello: { + world: 'earth', + }, + banjo: 'Steve Martin', + }, + }); + expect(actual).toEqual({ + id: 'universe', + type: 'hello', + attributes: { + world: 'earth', + }, + }); + }); + + test('it does not create attributes if [type] is missing', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'universe', + _type: ROOT_TYPE, + _source: { + type: 'hello', + }, + }); + expect(actual).toEqual({ + id: 'universe', + type: 'hello', + }); + }); + + test('it fails for documents which do not specify a type', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect(() => + serializer.rawToSavedObject({ + _id: 'universe', + _type: ROOT_TYPE, + _source: { + hello: { + world: 'earth', + }, + }, + }) + ).toThrow(/Expected "undefined" to be a saved object type/); + }); + + test('it is complimentary with savedObjectToRaw', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const raw = { + _id: 'foo-namespace:foo:bar', + _type: ROOT_TYPE, + _version: 24, + _source: { + type: 'foo', + foo: { + meaning: 42, + nested: { stuff: 'here' }, + }, + migrationVersion: { + foo: '1.2.3', + bar: '9.8.7', + }, + namespace: 'foo-namespace', + updated_at: new Date(), + }, + }; + + expect(serializer.savedObjectToRaw(serializer.rawToSavedObject(_.cloneDeep(raw)))).toEqual( + raw + ); + }); + + test('it handles unprefixed ids', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'universe', + _source: { + type: 'hello', + }, + }); + + expect(actual).toHaveProperty('id', 'universe'); + }); + + describe('namespaced type without a namespace', () => { + test(`removes type prefix from _id`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + + expect(actual).toHaveProperty('id', 'bar'); + }); + + test(`if prefixed by random prefix and type it copies _id to id`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'random:foo:bar', + _source: { + type: 'foo', + }, + }); + + expect(actual).toHaveProperty('id', 'random:foo:bar'); + }); + + test(`doesn't specify namespace`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + + expect(actual).not.toHaveProperty('namespace'); + }); + }); + + describe('namespaced type with a namespace', () => { + test(`removes type and namespace prefix from _id`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'baz:foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).toHaveProperty('id', 'bar'); + }); + + test(`if prefixed by only type it copies _id to id`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).toHaveProperty('id', 'foo:bar'); + }); + + test(`if prefixed by random prefix and type it copies _id to id`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'random:foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).toHaveProperty('id', 'random:foo:bar'); + }); + + test(`copies _source.namespace to namespace`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'baz:foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).toHaveProperty('namespace', 'baz'); + }); + }); + + describe('namespace agnostic type with a namespace', () => { + test(`removes type prefix from _id`, () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ foo: { isNamespaceAgnostic: true } }) + ); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).toHaveProperty('id', 'bar'); + }); + + test(`if prefixed by namespace and type it copies _id to id`, () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ foo: { isNamespaceAgnostic: true } }) + ); + const actual = serializer.rawToSavedObject({ + _id: 'baz:foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).toHaveProperty('id', 'baz:foo:bar'); + }); + + test(`doesn't copy _source.namespace to namespace`, () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ foo: { isNamespaceAgnostic: true } }) + ); + const actual = serializer.rawToSavedObject({ + _id: 'baz:foo:bar', + _source: { + type: 'foo', + namespace: 'baz', + }, + }); + + expect(actual).not.toHaveProperty('namespace'); + }); + }); + }); + + describe('#savedObjectToRaw', () => { + test('it copies the type property to _source.type and uses the ROOT_TYPE as _type', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: 'foo', + attributes: {}, + } as any); + + expect(actual).toHaveProperty('_type', ROOT_TYPE); + expect(actual._source).toHaveProperty('type', 'foo'); + }); + + test('if specified it copies the updated_at property to _source.updated_at', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const now = new Date(); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + updated_at: now, + } as any); + + expect(actual._source).toHaveProperty('updated_at', now); + }); + + test(`if unspecified it doesn't add updated_at property to _source`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('updated_at'); + }); + + test('it copies the migrationVersion property to _source.migrationVersion', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + migrationVersion: { + foo: '1.2.3', + bar: '9.8.7', + }, + } as any); + + expect(actual._source).toHaveProperty('migrationVersion', { + foo: '1.2.3', + bar: '9.8.7', + }); + }); + + test(`if unspecified it doesn't add migrationVersion property to _source`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('migrationVersion'); + }); + + test('it copies the version property to _version', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + version: 4, + } as any); + + expect(actual).toHaveProperty('_version', 4); + }); + + test(`if unspecified it doesn't add _version property`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual).not.toHaveProperty('_version'); + }); + + test('it copies attributes to _source[type]', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: 'foo', + attributes: { + foo: true, + bar: 'quz', + }, + } as any); + + expect(actual._source).toHaveProperty('foo', { + foo: true, + bar: 'quz', + }); + }); + + describe('namespaced type without a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const v1 = serializer.savedObjectToRaw({ + type: 'foo', + attributes: { + bar: true, + }, + } as any); + + const v2 = serializer.savedObjectToRaw({ + type: 'foo', + attributes: { + bar: true, + }, + } as any); + + expect(v1._id).toMatch(/foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespace`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('namespace'); + }); + }); + + describe('namespaced type with a namespace', () => { + test('generates an id prefixed with namespace and type, if no id is specified', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const v1 = serializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { + bar: true, + }, + } as any); + + const v2 = serializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { + bar: true, + }, + } as any); + + expect(v1._id).toMatch(/bar\:foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`it copies namespace to _source.namespace`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + type: 'foo', + attributes: {}, + namespace: 'bar', + } as any); + + expect(actual._source).toHaveProperty('namespace', 'bar'); + }); + }); + + describe('namespace agnostic type with a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ foo: { isNamespaceAgnostic: true } }) + ); + const v1 = serializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { + bar: true, + }, + } as any); + + const v2 = serializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { + bar: true, + }, + } as any); + + expect(v1._id).toMatch(/foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespace`, () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ foo: { isNamespaceAgnostic: true } }) + ); + const actual = serializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('namespace'); + }); + }); + }); + + describe('#isRawSavedObject', () => { + describe('namespaced type without a namespace', () => { + test('is true if the id is prefixed and the type matches', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + }, + }) + ).toBeTruthy(); + }); + + test('is false if the id is not prefixed', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute is missing', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + }, + }) + ).toBeFalsy(); + }); + + test(`is false if the type prefix omits the :`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute does not match the id', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + }, + }) + ).toBeFalsy(); + }); + + test('is false if there is no [type] attribute', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + }, + }) + ).toBeFalsy(); + }); + }); + + describe('namespaced type with a namespace', () => { + test('is true if the id is prefixed with type and namespace and the type matches', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeTruthy(); + }); + + test('is false if the id is not prefixed by anything', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed only with type and the type matches', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed only with namespace and the namespace matches', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'foo:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test(`is false if the id prefix omits the trailing :`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'foo:helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute is missing', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute does not match the id', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the namespace attribute does not match the id', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'bar:jam:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if there is no [type] attribute', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); + + describe('namespace agonstic type with a namespace', () => { + test('is true if the id is prefixed with type and the type matches', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeTruthy(); + }); + + test('is false if the id is not prefixed', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed with type and namespace', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test(`is false if the type prefix omits the :`, () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute is missing', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute does not match the id', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if there is no [type] attribute', () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ hello: { isNamespaceAgnostic: true } }) + ); + expect( + serializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); + }); + + describe('#generateRawId', () => { + describe('namespaced type without a namespace', () => { + test('generates an id if none is specified', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const id = serializer.generateRawId('', 'goodbye'); + expect(id).toMatch(/goodbye\:[\w-]+$/); + }); + + test('uses the id that is specified', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const id = serializer.generateRawId('', 'hello', 'world'); + expect(id).toMatch('hello:world'); + }); + }); + + describe('namespaced type with a namespace', () => { + test('generates an id if none is specified and prefixes namespace', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const id = serializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/foo:goodbye\:[\w-]+$/); + }); + + test('uses the id that is specified and prefixes the namespace', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const id = serializer.generateRawId('foo', 'hello', 'world'); + expect(id).toMatch('foo:hello:world'); + }); + }); + + describe('namespace agnostic type with a namespace', () => { + test(`generates an id if none is specified and doesn't prefix namespace`, () => { + const serializer = new SavedObjectsSerializer( + new SavedObjectsSchema({ foo: { isNamespaceAgnostic: true } }) + ); + const id = serializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/goodbye\:[\w-]+$/); + }); + + test(`uses the id that is specified and doesn't prefix the namespace`, () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const id = serializer.generateRawId('foo', 'hello', 'world'); + expect(id).toMatch('hello:world'); + }); + }); + }); +}); diff --git a/src/server/saved_objects/serialization/serializtion.test.ts b/src/server/saved_objects/serialization/serializtion.test.ts deleted file mode 100644 index 9f594ff31c44a..0000000000000 --- a/src/server/saved_objects/serialization/serializtion.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import { - generateRawId, - isRawSavedObject, - rawToSavedObject, - ROOT_TYPE, - savedObjectToRaw, -} from './index'; - -describe('saved object conversion', () => { - describe('rawToSavedObject', () => { - test('it converts the id and type properties, and retains migrationVersion', () => { - const now = new Date(); - const actual = rawToSavedObject({ - _id: 'hello:world', - _type: ROOT_TYPE, - _version: 3, - _source: { - type: 'hello', - hello: { - a: 'b', - c: 'd', - }, - migrationVersion: { - hello: '1.2.3', - acl: '33.3.5', - }, - updated_at: now, - }, - }); - const expected = { - id: 'world', - type: 'hello', - version: 3, - attributes: { - a: 'b', - c: 'd', - }, - migrationVersion: { - hello: '1.2.3', - acl: '33.3.5', - }, - updated_at: now, - }; - expect(expected).toEqual(actual); - }); - - test('it ignores version if not in the raw doc', () => { - const actual = rawToSavedObject({ - _id: 'hello:world', - _type: ROOT_TYPE, - _source: { - type: 'hello', - hello: { - world: 'earth', - }, - }, - }); - const expected = { - id: 'world', - type: 'hello', - attributes: { - world: 'earth', - }, - }; - expect(expected).toEqual(actual); - }); - - test('it handles unprefixed ids', () => { - const actual = rawToSavedObject({ - _id: 'universe', - _type: ROOT_TYPE, - _source: { - type: 'hello', - hello: { - world: 'earth', - }, - }, - }); - expect(actual.id).toEqual('universe'); - }); - - test('it does not pass unknown properties through', () => { - const actual = rawToSavedObject({ - _id: 'universe', - _type: ROOT_TYPE, - _source: { - type: 'hello', - hello: { - world: 'earth', - }, - banjo: 'Steve Martin', - }, - }); - expect(actual).toEqual({ - id: 'universe', - type: 'hello', - attributes: { - world: 'earth', - }, - }); - }); - - test('it does not create attributes if [type] is missing', () => { - const actual = rawToSavedObject({ - _id: 'universe', - _type: ROOT_TYPE, - _source: { - type: 'hello', - }, - }); - expect(actual).toEqual({ - id: 'universe', - type: 'hello', - }); - }); - - test('it fails for documents which do not specify a type', () => { - expect(() => - rawToSavedObject({ - _id: 'universe', - _type: ROOT_TYPE, - _source: { - hello: { - world: 'earth', - }, - }, - }) - ).toThrow(/Expected "undefined" to be a saved object type/); - }); - - test('it is complimentary with savedObjectToRaw', () => { - const raw = { - _id: 'foo:bar', - _type: ROOT_TYPE, - _version: 24, - _source: { - type: 'foo', - foo: { - meaning: 42, - nested: { stuff: 'here' }, - }, - migrationVersion: { - foo: '1.2.3', - bar: '9.8.7', - }, - updated_at: new Date(), - }, - }; - - expect(savedObjectToRaw(rawToSavedObject(_.cloneDeep(raw)))).toEqual(raw); - }); - }); - - test('savedObjectToRaw generates an id, if none is specified', () => { - const v1 = savedObjectToRaw({ - type: 'foo', - attributes: { - bar: true, - }, - } as any); - - const v2 = savedObjectToRaw({ - type: 'foo', - attributes: { - bar: true, - }, - } as any); - - expect(v1._id).toMatch(/foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - - describe('isRawSavedObject', () => { - test('is true if the id is prefixed and the type matches', () => { - expect( - isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed', () => { - expect( - isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - expect( - isRawSavedObject({ - _id: 'hello:world', - _source: { - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - expect( - isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - expect( - isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - }, - }) - ).toBeFalsy(); - }); - }); - - test('generateRawId generates an id, if none is specified', () => { - const id = generateRawId('goodbye'); - expect(id).toMatch(/goodbye\:[\w-]+$/); - }); - - test('generateRawId uses the id that is specified', () => { - const id = generateRawId('hello', 'world'); - expect(id).toMatch('hello:world'); - }); -}); diff --git a/src/server/saved_objects/service/create_saved_objects_service.js b/src/server/saved_objects/service/create_saved_objects_service.js index 25331153d2d94..c57d45effa8d7 100644 --- a/src/server/saved_objects/service/create_saved_objects_service.js +++ b/src/server/saved_objects/service/create_saved_objects_service.js @@ -30,7 +30,7 @@ import { SavedObjectsClient } from './saved_objects_client'; // import / export from the Kibana UI. Import / export functionality needs to apply migrations to documents. // Eventually, we hope to build a first-class import / export API, at which point, we can // remove the migrator from the saved objects client and leave only document validation here. -export function createSavedObjectsService(server, migrator) { +export function createSavedObjectsService(server, schema, serializer, migrator) { const onBeforeWrite = async () => { const adminCluster = server.plugins.elasticsearch.getCluster('admin'); @@ -72,6 +72,8 @@ export function createSavedObjectsService(server, migrator) { index: server.config().get('kibana.index'), migrator, mappings, + schema, + serializer, onBeforeWrite, }); diff --git a/src/server/saved_objects/service/lib/included_fields.js b/src/server/saved_objects/service/lib/included_fields.js index 3e849af7edd89..8c94d03008e70 100644 --- a/src/server/saved_objects/service/lib/included_fields.js +++ b/src/server/saved_objects/service/lib/included_fields.js @@ -32,6 +32,7 @@ export function includedFields(type, fields) { const sourceType = type || '*'; return sourceFields.map(f => `${sourceType}.${f}`) + .concat('namespace') .concat('type') .concat(fields); // v5 compatibility } diff --git a/src/server/saved_objects/service/lib/included_fields.test.js b/src/server/saved_objects/service/lib/included_fields.test.js index 441b78f533f6a..37935ab9153db 100644 --- a/src/server/saved_objects/service/lib/included_fields.test.js +++ b/src/server/saved_objects/service/lib/included_fields.test.js @@ -26,27 +26,33 @@ describe('includedFields', () => { it('includes type', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(3); + expect(fields).toHaveLength(4); expect(fields).toContain('type'); }); + it('includes namespace', () => { + const fields = includedFields('config', 'foo'); + expect(fields).toHaveLength(4); + expect(fields).toContain('namespace'); + }); + it('accepts field as string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(3); + expect(fields).toHaveLength(4); expect(fields).toContain('config.foo'); }); it('accepts fields as an array', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(5); + expect(fields).toHaveLength(6); expect(fields).toContain('config.foo'); expect(fields).toContain('config.bar'); }); it('uses wildcard when type is not provided', () => { const fields = includedFields(undefined, 'foo'); - expect(fields).toHaveLength(3); + expect(fields).toHaveLength(4); expect(fields).toContain('*.foo'); }); @@ -54,7 +60,7 @@ describe('includedFields', () => { it('includes legacy field path', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(5); + expect(fields).toHaveLength(6); expect(fields).toContain('foo'); expect(fields).toContain('bar'); }); diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index c03cb97e3e86d..1517cfada5058 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -17,14 +17,13 @@ * under the License. */ +import { omit } from 'lodash'; import { getRootType } from '../../../mappings'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; import { decorateEsError } from './decorate_es_error'; -import { savedObjectToRaw, rawToSavedObject, generateRawId } from '../../serialization'; import * as errors from './errors'; - // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -34,6 +33,8 @@ export class SavedObjectsRepository { index, mappings, callCluster, + schema, + serializer, migrator = { migrateDocument: (doc) => doc }, onBeforeWrite = () => { }, } = options; @@ -51,6 +52,8 @@ export class SavedObjectsRepository { this._type = getRootType(this._mappings); this._onBeforeWrite = onBeforeWrite; this._unwrappedCallCluster = callCluster; + this._schema = schema; + this._serializer = serializer; } /** @@ -62,6 +65,7 @@ export class SavedObjectsRepository { * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] * @property {object} [options.migrationVersion=undefined] + * @property {string} [options.namespace] * @returns {promise} - { id, type, version, attributes } */ async create(type, attributes = {}, options = {}) { @@ -69,6 +73,7 @@ export class SavedObjectsRepository { id, migrationVersion, overwrite = false, + namespace, } = options; const method = id && !overwrite ? 'create' : 'index'; @@ -78,12 +83,13 @@ export class SavedObjectsRepository { const migrated = this._migrator.migrateDocument({ id, type, + namespace, attributes, migrationVersion, updated_at: time, }); - const raw = savedObjectToRaw(migrated); + const raw = this._serializer.savedObjectToRaw(migrated); const response = await this._writeToCluster(method, { id: raw._id, @@ -93,7 +99,7 @@ export class SavedObjectsRepository { body: raw._source, }); - return rawToSavedObject({ + return this._rawToSavedObject({ ...raw, ...response, }); @@ -113,11 +119,13 @@ export class SavedObjectsRepository { * @param {array} objects - [{ type, id, attributes, migrationVersion }] * @param {object} [options={}] * @property {boolean} [options.overwrite=false] - overwrites existing documents + * @property {string} [options.namespace] * @returns {promise} - {saved_objects: [[{ id, type, version, attributes, error: { message } }]} */ async bulkCreate(objects, options = {}) { const { - overwrite = false + namespace, + overwrite = false, } = options; const time = this._getCurrentTime(); const objectToBulkRequest = (object) => { @@ -127,9 +135,10 @@ export class SavedObjectsRepository { type: object.type, attributes: object.attributes, migrationVersion: object.migrationVersion, + namespace, updated_at: time, }); - const raw = savedObjectToRaw(migrated); + const raw = this._serializer.savedObjectToRaw(migrated); return [ { @@ -198,11 +207,17 @@ export class SavedObjectsRepository { * * @param {string} type * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] * @returns {promise} */ - async delete(type, id) { + async delete(type, id, options = {}) { + const { + namespace + } = options; + const response = await this._writeToCluster('delete', { - id: generateRawId(type, id), + id: this._serializer.generateRawId(namespace, type, id), type: this._type, index: this._index, refresh: 'wait_for', @@ -237,6 +252,7 @@ export class SavedObjectsRepository { * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] + * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ async find(options = {}) { @@ -249,6 +265,7 @@ export class SavedObjectsRepository { sortField, sortOrder, fields, + namespace, } = options; if (!type) { @@ -271,12 +288,13 @@ export class SavedObjectsRepository { ignore: [404], body: { version: true, - ...getSearchDsl(this._mappings, { + ...getSearchDsl(this._mappings, this._schema, { search, searchFields, type, sortField, - sortOrder + sortOrder, + namespace, }) } }; @@ -298,7 +316,7 @@ export class SavedObjectsRepository { page, per_page: perPage, total: response.hits.total, - saved_objects: response.hits.hits.map(rawToSavedObject), + saved_objects: response.hits.hits.map(hit => this._rawToSavedObject(hit)), }; } @@ -306,6 +324,8 @@ export class SavedObjectsRepository { * Returns an array of objects by id * * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {object} [options={}] + * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } * @example * @@ -314,7 +334,11 @@ export class SavedObjectsRepository { * { id: 'foo', type: 'index-pattern' } * ]) */ - async bulkGet(objects = []) { + async bulkGet(objects = [], options = {}) { + const { + namespace + } = options; + if (objects.length === 0) { return { saved_objects: [] }; } @@ -323,7 +347,7 @@ export class SavedObjectsRepository { index: this._index, body: { docs: objects.map(object => ({ - _id: generateRawId(object.type, object.id), + _id: this._serializer.generateRawId(namespace, object.type, object.id), _type: this._type, })) } @@ -359,11 +383,17 @@ export class SavedObjectsRepository { * * @param {string} type * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] * @returns {promise} - { id, type, version, attributes } */ - async get(type, id) { + async get(type, id, options = {}) { + const { + namespace + } = options; + const response = await this._callCluster('get', { - id: generateRawId(type, id), + id: this._serializer.generateRawId(namespace, type, id), type: this._type, index: this._index, ignore: [404] @@ -395,15 +425,21 @@ export class SavedObjectsRepository { * @param {string} id * @param {object} [options={}] * @property {integer} options.version - ensures version matches that of persisted object + * @property {string} [options.namespace] * @returns {promise} */ async update(type, id, attributes, options = {}) { + const { + version, + namespace + } = options; + const time = this._getCurrentTime(); const response = await this._writeToCluster('update', { - id: generateRawId(type, id), + id: this._serializer.generateRawId(namespace, type, id), type: this._type, index: this._index, - version: options.version, + version, refresh: 'wait_for', ignore: [404], body: { @@ -448,4 +484,13 @@ export class SavedObjectsRepository { _getCurrentTime() { return new Date().toISOString(); } + + // The internal representation of the saved object that the serializer returns + // includes the namespace, and we use this for migrating documents. However, we don't + // want the namespcae to be returned from the repository, as the repository scopes each + // method transparently to the specified namespace. + _rawToSavedObject(raw) { + const savedObject = this._serializer.rawToSavedObject(raw); + return omit(savedObject, 'namespace'); + } } diff --git a/src/server/saved_objects/service/lib/repository.test.js b/src/server/saved_objects/service/lib/repository.test.js index 388f905aac2f1..6d832ea3c2d12 100644 --- a/src/server/saved_objects/service/lib/repository.test.js +++ b/src/server/saved_objects/service/lib/repository.test.js @@ -24,6 +24,8 @@ import * as getSearchDslNS from './search_dsl/search_dsl'; import { getSearchDsl } from './search_dsl'; import * as errors from './errors'; import elasticsearch from 'elasticsearch'; +import { SavedObjectsSchema } from '../../schema'; +import { SavedObjectsSerializer } from '../../serialization'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -37,9 +39,9 @@ describe('SavedObjectsRepository', () => { let migrator; const mockTimestamp = '2017-08-14T15:49:14.886Z'; const mockTimestampFields = { updated_at: mockTimestamp }; - const searchResults = { + const noNamespaceSearchResults = { hits: { - total: 3, + total: 4, hits: [{ _index: '.kibana', _type: 'doc', @@ -81,6 +83,81 @@ describe('SavedObjectsRepository', () => { notExpandable: true } } + }, { + _index: '.kibana', + _type: 'doc', + _id: 'globaltype:something', + _score: 1, + _source: { + type: 'globaltype', + ...mockTimestampFields, + 'globaltype': { + name: 'bar', + } + } + }] + } + }; + + const namespacedSearchResults = { + hits: { + total: 4, + hits: [{ + _index: '.kibana', + _type: 'doc', + _id: 'foo-namespace:index-pattern:logstash-*', + _score: 1, + _source: { + namespace: 'foo-namespace', + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true + } + } + }, { + _index: '.kibana', + _type: 'doc', + _id: 'foo-namespace:config:6.0.0-alpha1', + _score: 1, + _source: { + namespace: 'foo-namespace', + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8467, + defaultIndex: 'logstash-*' + } + } + }, { + _index: '.kibana', + _type: 'doc', + _id: 'foo-namespace:index-pattern:stocks-*', + _score: 1, + _source: { + namespace: 'foo-namespace', + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'stocks-*', + timeFieldName: '@timestamp', + notExpandable: true + } + } + }, { + _index: '.kibana', + _type: 'doc', + _id: 'globaltype:something', + _score: 1, + _source: { + type: 'globaltype', + ...mockTimestampFields, + 'globaltype': { + name: 'bar', + } + } }] } }; @@ -99,6 +176,8 @@ describe('SavedObjectsRepository', () => { } }; + const schema = new SavedObjectsSchema({ globaltype: { isNamespaceAgnostic: true } }); + beforeEach(() => { callAdminCluster = sandbox.stub(); onBeforeWrite = sandbox.stub(); @@ -106,11 +185,14 @@ describe('SavedObjectsRepository', () => { migrateDocument: sinon.spy((doc) => doc), }; + const serializer = new SavedObjectsSerializer(schema); savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', mappings, callCluster: callAdminCluster, migrator, + schema, + serializer, onBeforeWrite }); @@ -125,9 +207,9 @@ describe('SavedObjectsRepository', () => { describe('#create', () => { beforeEach(() => { - callAdminCluster.returns(Promise.resolve({ + callAdminCluster.callsFake((method, params) => ({ _type: 'doc', - _id: 'index-pattern:logstash-*', + _id: params.id, _version: 2 })); }); @@ -135,6 +217,9 @@ describe('SavedObjectsRepository', () => { it('formats Elasticsearch response', async () => { const response = await savedObjectsRepository.create('index-pattern', { title: 'Logstash' + }, { + id: 'logstash-*', + namespace: 'foo-namespace', }); expect(response).toEqual({ @@ -219,6 +304,72 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledOnce(onBeforeWrite); }); + + it('prepends namespace to the id and adds namespace to body when providing namespace for namespaced type', async () => { + await savedObjectsRepository.create('index-pattern', + { + title: 'Logstash' + }, + { + id: 'foo-id', + namespace: 'foo-namespace', + }, + ); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + id: `foo-namespace:index-pattern:foo-id`, + body: { + [`index-pattern`]: { title: 'Logstash' }, + namespace: 'foo-namespace', + type: 'index-pattern', + updated_at: '2017-08-14T15:49:14.886Z' + } + })); + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { + await savedObjectsRepository.create('index-pattern', + { + title: 'Logstash' + }, + { + id: 'foo-id' + } + ); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + id: `index-pattern:foo-id`, + body: { + [`index-pattern`]: { title: 'Logstash' }, + type: 'index-pattern', + updated_at: '2017-08-14T15:49:14.886Z' + } + })); + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { + await savedObjectsRepository.create('globaltype', + { + title: 'Logstash' + }, + { + id: 'foo-id', + namespace: 'foo-namespace', + } + ); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + id: `globaltype:foo-id`, + body: { + [`globaltype`]: { title: 'Logstash' }, + type: 'globaltype', + updated_at: '2017-08-14T15:49:14.886Z' + } + })); + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('#bulkCreate', () => { @@ -277,7 +428,7 @@ describe('SavedObjectsRepository', () => { body: [ // uses create because overwriting is not allowed { create: { _type: 'doc', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, 'foo': {}, migrationVersion: undefined }, + { type: 'foo', ...mockTimestampFields, 'foo': {} }, ] })); @@ -292,7 +443,7 @@ describe('SavedObjectsRepository', () => { body: [ // uses index because overwriting is allowed { index: { _type: 'doc', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, 'foo': {}, migrationVersion: undefined }, + { type: 'foo', ...mockTimestampFields, 'foo': {} }, ] })); @@ -362,7 +513,9 @@ describe('SavedObjectsRepository', () => { const response = await savedObjectsRepository.bulkCreate([ { type: 'config', id: 'one', attributes: { title: 'Test One' } }, { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } - ]); + ], { + namespace: 'foo-namespace', + }); expect(response).toEqual({ saved_objects: [ @@ -382,6 +535,67 @@ describe('SavedObjectsRepository', () => { ] }); }); + + it('prepends namespace to the id and adds namespace to body when providing namespace for namespaced type', async () => { + callAdminCluster.returns({ items: [] }); + await savedObjectsRepository.bulkCreate( + [ + { type: 'config', id: 'one', attributes: { title: 'Test One' } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + ], + { + namespace: 'foo-namespace', + }, + ); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ + body: [ + { create: { _type: 'doc', _id: 'foo-namespace:config:one' } }, + { namespace: 'foo-namespace', type: 'config', ...mockTimestampFields, config: { title: 'Test One' } }, + { create: { _type: 'doc', _id: 'foo-namespace:index-pattern:two' } }, + { namespace: 'foo-namespace', type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } } + ] + })); + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { + callAdminCluster.returns({ items: [] }); + await savedObjectsRepository.bulkCreate([ + { type: 'config', id: 'one', attributes: { title: 'Test One' } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + ]); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ + body: [ + { create: { _type: 'doc', _id: 'config:one' } }, + { type: 'config', ...mockTimestampFields, config: { title: 'Test One' } }, + { create: { _type: 'doc', _id: 'index-pattern:two' } }, + { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } } + ] + })); + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { + callAdminCluster.returns({ items: [] }); + await savedObjectsRepository.bulkCreate( + [ + { type: 'globaltype', id: 'one', attributes: { title: 'Test One' } }, + ], + { + namespace: 'foo-namespace', + }, + ); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ + body: [ + { create: { _type: 'doc', _id: 'globaltype:one' } }, + { type: 'globaltype', ...mockTimestampFields, 'globaltype': { title: 'Test One' } }, + ] + })); + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('#delete', () => { @@ -399,7 +613,27 @@ describe('SavedObjectsRepository', () => { } }); - it('passes the parameters to callAdminCluster', async () => { + it(`prepends namespace to the id when providing namespace for namespaced type`, async () => { + callAdminCluster.returns({ + result: 'deleted' + }); + await savedObjectsRepository.delete('index-pattern', 'logstash-*', { + namespace: 'foo-namespace', + }); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'delete', { + type: 'doc', + id: 'foo-namespace:index-pattern:logstash-*', + refresh: 'wait_for', + index: '.kibana-test', + ignore: [404], + }); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for namespaced type`, async () => { callAdminCluster.returns({ result: 'deleted' }); @@ -416,12 +650,29 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledOnce(onBeforeWrite); }); + + it(`doesn't prepend namespace to the id when providing namespace for namespace agnostic type`, async () => { + callAdminCluster.returns({ + result: 'deleted' + }); + await savedObjectsRepository.delete('globaltype', 'logstash-*', { + namespace: 'foo-namespace', + }); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'delete', { + type: 'doc', + id: 'globaltype:logstash-*', + refresh: 'wait_for', + index: '.kibana-test', + ignore: [404], + }); + + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('#find', () => { - beforeEach(() => { - callAdminCluster.returns(searchResults); - }); it('requires type to be defined', async () => { await expect(savedObjectsRepository.find({})).rejects.toThrow(/options\.type must be/); @@ -430,6 +681,7 @@ describe('SavedObjectsRepository', () => { }); it('requires searchFields be an array if defined', async () => { + callAdminCluster.returns(noNamespaceSearchResults); try { await savedObjectsRepository.find({ type: 'foo', searchFields: 'string' }); throw new Error('expected find() to reject'); @@ -441,6 +693,7 @@ describe('SavedObjectsRepository', () => { }); it('requires fields be an array if defined', async () => { + callAdminCluster.returns(noNamespaceSearchResults); try { await savedObjectsRepository.find({ type: 'foo', fields: 'string' }); throw new Error('expected find() to reject'); @@ -451,8 +704,10 @@ describe('SavedObjectsRepository', () => { } }); - it('passes mappings, search, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => { + it('passes mappings, schema, search, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => { + callAdminCluster.returns(namespacedSearchResults); const relevantOpts = { + namespace: 'foo-namespace', search: 'foo*', searchFields: ['foo'], type: 'bar', @@ -462,10 +717,11 @@ describe('SavedObjectsRepository', () => { await savedObjectsRepository.find(relevantOpts); sinon.assert.calledOnce(getSearchDsl); - sinon.assert.calledWithExactly(getSearchDsl, mappings, relevantOpts); + sinon.assert.calledWithExactly(getSearchDsl, mappings, schema, relevantOpts); }); it('merges output of getSearchDsl into es request body', async () => { + callAdminCluster.returns(noNamespaceSearchResults); getSearchDsl.returns({ query: 1, aggregations: 2 }); await savedObjectsRepository.find({ type: 'foo' }); sinon.assert.calledOnce(callAdminCluster); @@ -478,17 +734,41 @@ describe('SavedObjectsRepository', () => { })); }); - it('formats Elasticsearch response', async () => { - const count = searchResults.hits.hits.length; + it('formats Elasticsearch response when there is no namespace', async () => { + callAdminCluster.returns(noNamespaceSearchResults); + const count = noNamespaceSearchResults.hits.hits.length; const response = await savedObjectsRepository.find({ type: 'foo' }); expect(response.total).toBe(count); expect(response.saved_objects).toHaveLength(count); - searchResults.hits.hits.forEach((doc, i) => { + noNamespaceSearchResults.hits.hits.forEach((doc, i) => { + expect(response.saved_objects[i]).toEqual({ + id: doc._id.replace(/(index-pattern|config|globaltype)\:/, ''), + type: doc._source.type, + ...mockTimestampFields, + version: doc._version, + attributes: doc._source[doc._source.type] + }); + }); + }); + + it('formats Elasticsearch response when there is a namespace', async () => { + callAdminCluster.returns(namespacedSearchResults); + const count = namespacedSearchResults.hits.hits.length; + + const response = await savedObjectsRepository.find({ + type: 'foo', + namespace: 'foo-namespace', + }); + + expect(response.total).toBe(count); + expect(response.saved_objects).toHaveLength(count); + + namespacedSearchResults.hits.hits.forEach((doc, i) => { expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(index-pattern|config)\:/, ''), + id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globaltype)\:/, ''), type: doc._source.type, ...mockTimestampFields, version: doc._version, @@ -498,6 +778,7 @@ describe('SavedObjectsRepository', () => { }); it('accepts per_page/page', async () => { + callAdminCluster.returns(noNamespaceSearchResults); await savedObjectsRepository.find({ type: 'foo', perPage: 10, page: 6 }); sinon.assert.calledOnce(callAdminCluster); @@ -510,12 +791,13 @@ describe('SavedObjectsRepository', () => { }); it('can filter by fields', async () => { + callAdminCluster.returns(noNamespaceSearchResults); await savedObjectsRepository.find({ type: 'foo', fields: ['title'] }); sinon.assert.calledOnce(callAdminCluster); sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ _source: [ - 'foo.title', 'type', 'title' + 'foo.title', 'namespace', 'type', 'title' ] })); @@ -524,22 +806,51 @@ describe('SavedObjectsRepository', () => { }); describe('#get', () => { - beforeEach(() => { - callAdminCluster.returns(Promise.resolve({ - _id: 'index-pattern:logstash-*', - _type: 'doc', - _version: 2, - _source: { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'Testing' - } + const noNamespaceResult = { + _id: 'index-pattern:logstash-*', + _type: 'doc', + _version: 2, + _source: { + type: 'index-pattern', + specialProperty: 'specialValue', + ...mockTimestampFields, + 'index-pattern': { + title: 'Testing' } - })); + } + }; + const namespacedResult = { + _id: 'foo-namespace:index-pattern:logstash-*', + _type: 'doc', + _version: 2, + _source: { + namespace: 'foo-namespace', + type: 'index-pattern', + specialProperty: 'specialValue', + ...mockTimestampFields, + 'index-pattern': { + title: 'Testing' + } + } + }; + + it('formats Elasticsearch response when there is no namespace', async () => { + callAdminCluster.returns(Promise.resolve(noNamespaceResult)); + const response = await savedObjectsRepository.get('index-pattern', 'logstash-*'); + sinon.assert.notCalled(onBeforeWrite); + expect(response).toEqual({ + id: 'logstash-*', + type: 'index-pattern', + updated_at: mockTimestamp, + version: 2, + attributes: { + title: 'Testing' + } + }); }); - it('formats Elasticsearch response', async () => { + it('formats Elasticsearch response when there are namespaces', async () => { + callAdminCluster.returns(Promise.resolve(namespacedResult)); const response = await savedObjectsRepository.get('index-pattern', 'logstash-*'); sinon.assert.notCalled(onBeforeWrite); expect(response).toEqual({ @@ -553,7 +864,22 @@ describe('SavedObjectsRepository', () => { }); }); - it('prepends type to the id', async () => { + it('prepends namespace and type to the id when providing namespace for namespaced type', async () => { + callAdminCluster.returns(Promise.resolve(namespacedResult)); + await savedObjectsRepository.get('index-pattern', 'logstash-*', { + namespace: 'foo-namespace', + }); + + sinon.assert.notCalled(onBeforeWrite); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + id: 'foo-namespace:index-pattern:logstash-*', + type: 'doc' + })); + }); + + it(`only prepends type to the id when providing no namespace for namespaced type`, async () => { + callAdminCluster.returns(Promise.resolve(noNamespaceResult)); await savedObjectsRepository.get('index-pattern', 'logstash-*'); sinon.assert.notCalled(onBeforeWrite); @@ -563,15 +889,30 @@ describe('SavedObjectsRepository', () => { type: 'doc' })); }); + + it(`doesn't prepend namespace to the id when providing namespace for namespace agnostic type`, async () => { + callAdminCluster.returns(Promise.resolve(namespacedResult)); + await savedObjectsRepository.get('globaltype', 'logstash-*', { + namespace: 'foo-namespace', + }); + + sinon.assert.notCalled(onBeforeWrite); + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + id: 'globaltype:logstash-*', + type: 'doc' + })); + }); }); describe('#bulkGet', () => { - it('accepts a array of mixed type and ids', async () => { + it('prepends type to id when getting objects when there is no namespace', async () => { callAdminCluster.returns({ docs: [] }); await savedObjectsRepository.bulkGet([ { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' } + { id: 'two', type: 'index-pattern' }, + { id: 'three', type: 'globaltype' }, ]); sinon.assert.calledOnce(callAdminCluster); @@ -579,7 +920,35 @@ describe('SavedObjectsRepository', () => { body: { docs: [ { _type: 'doc', _id: 'config:one' }, - { _type: 'doc', _id: 'index-pattern:two' } + { _type: 'doc', _id: 'index-pattern:two' }, + { _type: 'doc', _id: 'globaltype:three' }, + ] + } + })); + + sinon.assert.notCalled(onBeforeWrite); + }); + + it('prepends namespace and type appropriately to id when getting objects when there is a namespace', async () => { + callAdminCluster.returns({ docs: [] }); + + await savedObjectsRepository.bulkGet( + [ + { id: 'one', type: 'config' }, + { id: 'two', type: 'index-pattern' }, + { id: 'three', type: 'globaltype' }, + ], { + namespace: 'foo-namespace', + } + ); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({ + body: { + docs: [ + { _type: 'doc', _id: 'foo-namespace:config:one' }, + { _type: 'doc', _id: 'foo-namespace:index-pattern:two' }, + { _type: 'doc', _id: 'globaltype:three' }, ] } })); @@ -651,7 +1020,7 @@ describe('SavedObjectsRepository', () => { }); it('returns current ES document version', async () => { - const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes); + const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, { namespace: 'foo-namespace' }); expect(response).toEqual({ id, type, @@ -675,7 +1044,30 @@ describe('SavedObjectsRepository', () => { })); }); - it('passes the parameters to callAdminCluster', async () => { + it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { + await savedObjectsRepository.update('index-pattern', 'logstash-*', { + title: 'Testing', + }, { + namespace: 'foo-namespace', + }); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'update', { + type: 'doc', + id: 'foo-namespace:index-pattern:logstash-*', + version: undefined, + body: { + doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } } + }, + ignore: [404], + refresh: 'wait_for', + index: '.kibana-test' + }); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { await savedObjectsRepository.update('index-pattern', 'logstash-*', { title: 'Testing' }); sinon.assert.calledOnce(callAdminCluster); @@ -693,6 +1085,29 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledOnce(onBeforeWrite); }); + + it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { + await savedObjectsRepository.update('globaltype', 'foo', { + name: 'bar', + }, { + namespace: 'foo-namespace', + }); + + sinon.assert.calledOnce(callAdminCluster); + sinon.assert.calledWithExactly(callAdminCluster, 'update', { + type: 'doc', + id: 'globaltype:foo', + version: undefined, + body: { + doc: { updated_at: mockTimestamp, 'globaltype': { name: 'bar' } } + }, + ignore: [404], + refresh: 'wait_for', + index: '.kibana-test' + }); + + sinon.assert.calledOnce(onBeforeWrite); + }); }); describe('onBeforeWrite', () => { diff --git a/src/server/saved_objects/service/lib/repository_provider.js b/src/server/saved_objects/service/lib/repository_provider.js index 805887c9a24b5..4c5a2f3124f05 100644 --- a/src/server/saved_objects/service/lib/repository_provider.js +++ b/src/server/saved_objects/service/lib/repository_provider.js @@ -28,11 +28,15 @@ export class SavedObjectsRepositoryProvider { index, mappings, migrator, + schema, + serializer, onBeforeWrite }) { this._index = index; this._mappings = mappings; this._migrator = migrator; + this._schema = schema; + this._serializer = serializer; this._onBeforeWrite = onBeforeWrite; } @@ -46,6 +50,8 @@ export class SavedObjectsRepositoryProvider { index: this._index, mappings: this._mappings, migrator: this._migrator, + schema: this._schema, + serializer: this._serializer, onBeforeWrite: this._onBeforeWrite, callCluster, }); diff --git a/src/server/saved_objects/service/lib/repository_provider.test.js b/src/server/saved_objects/service/lib/repository_provider.test.js index bc39fcecf9384..567c50a6a9fdd 100644 --- a/src/server/saved_objects/service/lib/repository_provider.test.js +++ b/src/server/saved_objects/service/lib/repository_provider.test.js @@ -17,7 +17,11 @@ * under the License. */ +import { SavedObjectsRepository } from './repository'; import { SavedObjectsRepositoryProvider } from './repository_provider'; +jest.mock('./repository', () => ({ + SavedObjectsRepository: jest.fn() +})); test('requires "callCluster" to be provided', () => { const provider = new SavedObjectsRepositoryProvider({ @@ -32,31 +36,36 @@ test('requires "callCluster" to be provided', () => { }); test('creates a valid Repository', async () => { - const properties = { - index: 'default-index', - mappings: { - foo: { - properties: { - field: { type: 'string' } - } - } - }, - onBeforeWrite: jest.fn() + const index = Symbol(); + const mappings = Symbol(); + const migrator = Symbol(); + const schema = Symbol(); + const serializer = Symbol(); + const onBeforeWrite = Symbol(); + const provider = new SavedObjectsRepositoryProvider({ + index, + mappings, + migrator, + schema, + serializer, + onBeforeWrite, + }); + const callCluster = () => {}; + const expectedReturnValue = { + foo: 'bar', }; + SavedObjectsRepository.mockImplementation(() => expectedReturnValue); - const provider = new SavedObjectsRepositoryProvider(properties); + const actualReturnValue = provider.getRepository(callCluster); - const callCluster = jest.fn().mockReturnValue({ - _id: 'new' + expect(actualReturnValue).toEqual(expectedReturnValue); + expect(SavedObjectsRepository).toHaveBeenCalledWith({ + index, + mappings, + migrator, + schema, + serializer, + onBeforeWrite, + callCluster, }); - - const repository = provider.getRepository(callCluster); - - await repository.create('foo', {}); - - expect(callCluster).toHaveBeenCalledTimes(1); - expect(properties.onBeforeWrite).toHaveBeenCalledTimes(1); - expect(callCluster).toHaveBeenCalledWith('index', expect.objectContaining({ - index: properties.index - })); -}); \ No newline at end of file +}); diff --git a/src/server/saved_objects/service/lib/search_dsl/query_params.js b/src/server/saved_objects/service/lib/search_dsl/query_params.js index bcf62f21ef415..5bcff743c1082 100644 --- a/src/server/saved_objects/service/lib/search_dsl/query_params.js +++ b/src/server/saved_objects/service/lib/search_dsl/query_params.js @@ -59,36 +59,66 @@ function getFieldsForTypes(searchFields, types) { }; } +/** + * Gets the clause that will filter for the type in the namespace. + * Some types are namespace agnostic, so they must be treated differently. + * @param {SavedObjectsSchema} schema + * @param {string} namespace + * @param {string} type + * @return {Object} + */ +function getClauseForType(schema, namespace, type) { + if (!type) { + throw new Error(`type is required to build filter clause`); + } + + if (namespace && !schema.isNamespaceAgnostic(type)) { + return { + bool: { + must: [ + { term: { type } }, + { term: { namespace } }, + ] + } + }; + } + + return { + bool: { + must: [{ term: { type } }], + must_not: [{ exists: { field: 'namespace' } }] + } + }; +} + /** * Get the "query" related keys for the search body * @param {EsMapping} mapping mappings from Ui + * *@param {SavedObjectsSchema} schema * @param {(string|Array)} type * @param {String} search * @param {Array} searchFields * @return {Object} */ -export function getQueryParams(mappings, type, search, searchFields) { - if (!type && !search) { - return {}; - } - - const bool = {}; - - if (type) { - bool.filter = [ - { [Array.isArray(type) ? 'terms' : 'term']: { type } } - ]; - } +export function getQueryParams(mappings, schema, namespace, type, search, searchFields) { + const types = getTypes(mappings, type); + const bool = { + filter: [{ + bool: { + should: types.map(type => getClauseForType(schema, namespace, type)), + minimum_should_match: 1 + } + }], + }; if (search) { bool.must = [ - ...bool.must || [], { simple_query_string: { query: search, ...getFieldsForTypes( searchFields, - getTypes(mappings, type) + types ) } } diff --git a/src/server/saved_objects/service/lib/search_dsl/query_params.test.js b/src/server/saved_objects/service/lib/search_dsl/query_params.test.js index 53b943ee6793b..45f241ba41883 100644 --- a/src/server/saved_objects/service/lib/search_dsl/query_params.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/query_params.test.js @@ -50,43 +50,225 @@ const MAPPINGS = { } } } + }, + global: { + properties: { + name: { + type: 'keyword', + } + } } } } }; +const SCHEMA = { + isNamespaceAgnostic: type => type === 'global', +}; + + +// create a type clause to be used within the "should", if a namespace is specified +// the clause will ensure the namespace matches; otherwise, the clause will ensure +// that there isn't a namespace field. +const createTypeClause = (type, namespace) => { + if (namespace) { + return { + bool: { + must: [ + { term: { type } }, + { term: { namespace } }, + ] + } + }; + } + + return { + bool: { + must: [{ term: { type } }], + must_not: [{ exists: { field: 'namespace' } }] + } + }; +}; + describe('searchDsl/queryParams', () => { - describe('{}', () => { - it('searches for everything', () => { - expect(getQueryParams(MAPPINGS)) - .toEqual({}); + describe('no parameters', () => { + it('searches for all known types without a namespace specified', () => { + expect(getQueryParams(MAPPINGS, SCHEMA)) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending'), + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }] + } + } + }); + }); + }); + + describe('namespace', () => { + it('filters namespaced types for namespace, and ensures namespace agnostic types have no namespace', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace')) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }] + } + } + }); + }); + }); + + describe('type (singular, namespaced)', () => { + it('includes a terms filter for type and namespace not being specified', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, 'saved')) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('saved'), + ], + minimum_should_match: 1 + } + }] + } + } + }); + }); + }); + + describe('type (singular, global)', () => { + it('includes a terms filter for type and namespace not being specified', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, 'global')) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }] + } + } + }); + }); + }); + + describe('type (plural, namespaced and global)', () => { + it('includes term filters for types and namespace not being specified', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, ['saved', 'global'])) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }] + } + } + }); }); }); - describe('{type}', () => { - it('adds a term filter when a string', () => { - expect(getQueryParams(MAPPINGS, 'saved')) + describe('namespace, type (plural, namespaced and global)', () => { + it('includes a terms filter for type and namespace not being specified', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'])) .toEqual({ query: { bool: { - filter: [ + filter: [{ + bool: { + should: [ + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }] + } + } + }); + }); + }); + + describe('search', () => { + it('includes a sqs query and all known types without a namespace specified', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, null, 'us*')) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending'), + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], + must: [ { - term: { type: 'saved' } + simple_query_string: { + query: 'us*', + all_fields: true + } } ] } } }); }); + }); - it('adds a terms filter when an array', () => { - expect(getQueryParams(MAPPINGS, ['saved', 'vis'])) + describe('namespace, search', () => { + it('includes a sqs query and namespaced types with the namespace and global types without a namespace', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', null, 'us*')) .toEqual({ query: { bool: { - filter: [ + filter: [{ + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], + must: [ { - terms: { type: ['saved', 'vis'] } + simple_query_string: { + query: 'us*', + all_fields: true + } } ] } @@ -95,12 +277,21 @@ describe('searchDsl/queryParams', () => { }); }); - describe('{search}', () => { - it('includes just a sqs query', () => { - expect(getQueryParams(MAPPINGS, null, 'us*')) + describe('type (plural, namespaced and global), search', () => { + it('includes a sqs query and types without a namespace', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, ['saved', 'global'], 'us*')) .toEqual({ query: { bool: { + filter: [{ + bool: { + should: [ + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { @@ -115,19 +306,25 @@ describe('searchDsl/queryParams', () => { }); }); - describe('{type,search}', () => { - it('includes bool with sqs query and term filter for type', () => { - expect(getQueryParams(MAPPINGS, 'saved', 'y*')) + describe('namespace, type (plural, namespaced and global), search', () => { + it('includes a sqs query and namespace type with a namespace and global type without a namespace', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'us*')) .toEqual({ query: { bool: { - filter: [ - { term: { type: 'saved' } } - ], + filter: [{ + bool: { + should: [ + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { - query: 'y*', + query: 'us*', all_fields: true } } @@ -136,19 +333,98 @@ describe('searchDsl/queryParams', () => { } }); }); - it('includes bool with sqs query and terms filter for type', () => { - expect(getQueryParams(MAPPINGS, ['saved', 'vis'], 'y*')) + }); + + describe('search, searchFields', () => { + it('includes all types for field', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, null, 'y*', ['title'])) .toEqual({ query: { bool: { - filter: [ - { terms: { type: ['saved', 'vis'] } } - ], + filter: [{ + bool: { + should: [ + createTypeClause('pending'), + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', - all_fields: true + fields: [ + 'pending.title', + 'saved.title', + 'global.title', + ] + } + } + ] + } + } + }); + }); + it('supports field boosting', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, null, 'y*', ['title^3'])) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending'), + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'pending.title^3', + 'saved.title^3', + 'global.title^3', + ] + } + } + ] + } + } + }); + }); + it('supports field and multi-field', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, null, 'y*', ['title', 'title.raw'])) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending'), + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'pending.title', + 'saved.title', + 'global.title', + 'pending.title.raw', + 'saved.title.raw', + 'global.title.raw', + ] } } ] @@ -158,19 +434,30 @@ describe('searchDsl/queryParams', () => { }); }); - describe('{search,searchFields}', () => { + describe('namespace, search, searchFields', () => { it('includes all types for field', () => { - expect(getQueryParams(MAPPINGS, null, 'y*', ['title'])) + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', null, 'y*', ['title'])) .toEqual({ query: { bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', fields: [ 'pending.title', - 'saved.title' + 'saved.title', + 'global.title', ] } } @@ -180,17 +467,28 @@ describe('searchDsl/queryParams', () => { }); }); it('supports field boosting', () => { - expect(getQueryParams(MAPPINGS, null, 'y*', ['title^3'])) + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', null, 'y*', ['title^3'])) .toEqual({ query: { bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', fields: [ 'pending.title^3', - 'saved.title^3' + 'saved.title^3', + 'global.title^3', ] } } @@ -200,10 +498,20 @@ describe('searchDsl/queryParams', () => { }); }); it('supports field and multi-field', () => { - expect(getQueryParams(MAPPINGS, null, 'y*', ['title', 'title.raw'])) + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', null, 'y*', ['title', 'title.raw'])) .toEqual({ query: { bool: { + filter: [{ + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { @@ -211,8 +519,10 @@ describe('searchDsl/queryParams', () => { fields: [ 'pending.title', 'saved.title', + 'global.title', 'pending.title.raw', 'saved.title.raw', + 'global.title.raw', ] } } @@ -223,21 +533,28 @@ describe('searchDsl/queryParams', () => { }); }); - describe('{type,search,searchFields}', () => { - it('includes bool, with term filter and sqs with field list', () => { - expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title'])) + describe('type (plural, namespaced and global), search, searchFields', () => { + it('includes all types for field', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, ['saved', 'global'], 'y*', ['title'])) .toEqual({ query: { bool: { - filter: [ - { term: { type: 'saved' } } - ], + filter: [{ + bool: { + should: [ + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', fields: [ - 'saved.title' + 'saved.title', + 'global.title', ] } } @@ -246,21 +563,58 @@ describe('searchDsl/queryParams', () => { } }); }); - it('includes bool, with terms filter and sqs with field list', () => { - expect(getQueryParams(MAPPINGS, ['saved', 'vis'], 'y*', ['title'])) + it('supports field boosting', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, ['saved', 'global'], 'y*', ['title^3'])) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'saved.title^3', + 'global.title^3', + ] + } + } + ] + } + } + }); + }); + it('supports field and multi-field', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, null, ['saved', 'global'], 'y*', ['title', 'title.raw'])) .toEqual({ query: { bool: { - filter: [ - { terms: { type: ['saved', 'vis'] } } - ], + filter: [{ + bool: { + should: [ + createTypeClause('saved'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', fields: [ 'saved.title', - 'vis.title' + 'global.title', + 'saved.title.raw', + 'global.title.raw', ] } } @@ -269,20 +623,30 @@ describe('searchDsl/queryParams', () => { } }); }); - it('supports fields pointing to multi-fields', () => { - expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title.raw'])) + }); + + describe('namespace, type (plural, namespaced and global), search, searchFields', () => { + it('includes all types for field', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title'])) .toEqual({ query: { bool: { - filter: [ - { term: { type: 'saved' } } - ], + filter: [{ + bool: { + should: [ + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', fields: [ - 'saved.title.raw' + 'saved.title', + 'global.title', ] } } @@ -291,21 +655,58 @@ describe('searchDsl/queryParams', () => { } }); }); - it('supports multiple search fields', () => { - expect(getQueryParams(MAPPINGS, 'saved', 'y*', ['title', 'title.raw'])) + it('supports field boosting', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title^3'])) .toEqual({ query: { bool: { - filter: [ - { term: { type: 'saved' } } - ], + filter: [{ + bool: { + should: [ + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], + must: [ + { + simple_query_string: { + query: 'y*', + fields: [ + 'saved.title^3', + 'global.title^3', + ] + } + } + ] + } + } + }); + }); + it('supports field and multi-field', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title', 'title.raw'])) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + should: [ + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1 + } + }], must: [ { simple_query_string: { query: 'y*', fields: [ 'saved.title', - 'saved.title.raw' + 'global.title', + 'saved.title.raw', + 'global.title.raw', ] } } diff --git a/src/server/saved_objects/service/lib/search_dsl/search_dsl.js b/src/server/saved_objects/service/lib/search_dsl/search_dsl.js index 1197a332f3410..a0434aa2f9f8b 100644 --- a/src/server/saved_objects/service/lib/search_dsl/search_dsl.js +++ b/src/server/saved_objects/service/lib/search_dsl/search_dsl.js @@ -22,13 +22,14 @@ import Boom from 'boom'; import { getQueryParams } from './query_params'; import { getSortingParams } from './sorting_params'; -export function getSearchDsl(mappings, options = {}) { +export function getSearchDsl(mappings, schema, options = {}) { const { type, search, searchFields, sortField, - sortOrder + sortOrder, + namespace, } = options; if (!type) { @@ -40,7 +41,7 @@ export function getSearchDsl(mappings, options = {}) { } return { - ...getQueryParams(mappings, type, search, searchFields), + ...getQueryParams(mappings, schema, namespace, type, search, searchFields), ...getSortingParams(mappings, type, sortField, sortOrder), }; } diff --git a/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js b/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js index 82796b5638ccf..18c1aa4e9a4fc 100644 --- a/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js @@ -29,7 +29,7 @@ describe('getSearchDsl', () => { describe('validation', () => { it('throws when type is not specified', () => { expect(() => { - getSearchDsl({}, { + getSearchDsl({}, {}, { type: undefined, sortField: 'title' }); @@ -37,7 +37,7 @@ describe('getSearchDsl', () => { }); it('throws when sortOrder without sortField', () => { expect(() => { - getSearchDsl({}, { + getSearchDsl({}, {}, { type: 'foo', sortOrder: 'desc' }); @@ -46,20 +46,24 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, type, search, searchFields) to getQueryParams', () => { + it('passes (mappings, schema, namespace, type, search, searchFields) to getQueryParams', () => { const spy = sandbox.spy(queryParamsNS, 'getQueryParams'); const mappings = { type: { properties: {} } }; + const schema = { isNamespaceAgnostic: () => {} }; const opts = { + namespace: 'foo-namespace', type: 'foo', search: 'bar', searchFields: ['baz'], }; - getSearchDsl(mappings, opts); + getSearchDsl(mappings, schema, opts); sinon.assert.calledOnce(spy); sinon.assert.calledWithExactly( spy, mappings, + schema, + opts.namespace, opts.type, opts.search, opts.searchFields, @@ -69,13 +73,14 @@ describe('getSearchDsl', () => { it('passes (mappings, type, sortField, sortOrder) to getSortingParams', () => { const spy = sandbox.stub(sortParamsNS, 'getSortingParams').returns({}); const mappings = { type: { properties: {} } }; + const schema = { isNamespaceAgnostic: () => {} }; const opts = { type: 'foo', sortField: 'bar', sortOrder: 'baz' }; - getSearchDsl(mappings, opts); + getSearchDsl(mappings, schema, opts); sinon.assert.calledOnce(spy); sinon.assert.calledWithExactly( spy, @@ -89,7 +94,7 @@ describe('getSearchDsl', () => { it('returns combination of getQueryParams and getSortingParams', () => { sandbox.stub(queryParamsNS, 'getQueryParams').returns({ a: 'a' }); sandbox.stub(sortParamsNS, 'getSortingParams').returns({ b: 'b' }); - expect(getSearchDsl(null, { type: 'foo' })).toEqual({ a: 'a', b: 'b' }); + expect(getSearchDsl(null, null, { type: 'foo' })).toEqual({ a: 'a', b: 'b' }); }); }); }); diff --git a/src/server/saved_objects/service/lib/search_dsl/sorting_params.js b/src/server/saved_objects/service/lib/search_dsl/sorting_params.js index b977924ff62c5..bf22e2818939c 100644 --- a/src/server/saved_objects/service/lib/search_dsl/sorting_params.js +++ b/src/server/saved_objects/service/lib/search_dsl/sorting_params.js @@ -26,24 +26,29 @@ export function getSortingParams(mappings, type, sortField, sortOrder) { return {}; } + let typeField = type; + if (Array.isArray(type)) { - const rootField = getProperty(mappings, sortField); - if (!rootField) { - throw Boom.badRequest(`Unable to sort multiple types by field ${sortField}, not a root property`); - } + if (type.length === 1) { + typeField = type[0]; + } else { + const rootField = getProperty(mappings, sortField); + if (!rootField) { + throw Boom.badRequest(`Unable to sort multiple types by field ${sortField}, not a root property`); + } - return { - sort: [{ - [sortField]: { - order: sortOrder, - unmapped_type: rootField.type - } - }] - }; + return { + sort: [{ + [sortField]: { + order: sortOrder, + unmapped_type: rootField.type + } + }] + }; + } } - - const key = `${type}.${sortField}`; + const key = `${typeField}.${sortField}`; const field = getProperty(mappings, key); if (!field) { throw Boom.badRequest(`Unknown sort field ${sortField}`); diff --git a/src/server/saved_objects/service/lib/search_dsl/sorting_params.test.js b/src/server/saved_objects/service/lib/search_dsl/sorting_params.test.js index a7ff1041f313a..71833f1395659 100644 --- a/src/server/saved_objects/service/lib/search_dsl/sorting_params.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/sorting_params.test.js @@ -138,6 +138,21 @@ describe('searchDsl/getSortParams', () => { }); }); }); + describe('sortField is multi-field with single type as array', () => { + it('returns correct params', () => { + expect(getSortingParams(MAPPINGS, ['saved'], 'title.raw')) + .toEqual({ + sort: [ + { + 'saved.title.raw': { + order: undefined, + unmapped_type: 'keyword' + } + } + ] + }); + }); + }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { expect(getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw')) diff --git a/src/server/saved_objects/service/saved_objects_client.js b/src/server/saved_objects/service/saved_objects_client.js index 7741800fc2e27..f1dc4dfbbc190 100644 --- a/src/server/saved_objects/service/saved_objects_client.js +++ b/src/server/saved_objects/service/saved_objects_client.js @@ -102,6 +102,7 @@ export class SavedObjectsClient { * @param {object} [options={}] * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] + * @property {string} [options.namespace] * @returns {promise} - { id, type, version, attributes } */ async create(type, attributes = {}, options = {}) { @@ -114,6 +115,7 @@ export class SavedObjectsClient { * @param {array} objects - [{ type, id, attributes }] * @param {object} [options={}] * @property {boolean} [options.overwrite=false] - overwrites existing documents + * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes, error: { message } }]} */ async bulkCreate(objects, options = {}) { @@ -125,10 +127,12 @@ export class SavedObjectsClient { * * @param {string} type * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] * @returns {promise} */ - async delete(type, id) { - return this._repository.delete(type, id); + async delete(type, id, options = {}) { + return this._repository.delete(type, id, options); } /** @@ -142,6 +146,7 @@ export class SavedObjectsClient { * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] + * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ async find(options = {}) { @@ -152,6 +157,8 @@ export class SavedObjectsClient { * Returns an array of objects by id * * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {object} [options={}] + * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } * @example * @@ -160,8 +167,8 @@ export class SavedObjectsClient { * { id: 'foo', type: 'index-pattern' } * ]) */ - async bulkGet(objects = []) { - return this._repository.bulkGet(objects); + async bulkGet(objects = [], options = {}) { + return this._repository.bulkGet(objects, options); } /** @@ -169,10 +176,12 @@ export class SavedObjectsClient { * * @param {string} type * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] * @returns {promise} - { id, type, version, attributes } */ - async get(type, id) { - return this._repository.get(type, id); + async get(type, id, options = {}) { + return this._repository.get(type, id, options); } /** @@ -182,6 +191,7 @@ export class SavedObjectsClient { * @param {string} id * @param {object} [options={}] * @property {integer} options.version - ensures version matches that of persisted object + * @property {string} [options.namespace] * @returns {promise} */ async update(type, id, attributes, options = {}) { diff --git a/src/server/saved_objects/service/saved_objects_client.test.js b/src/server/saved_objects/service/saved_objects_client.test.js index 930ccb3922c2d..e69b12d1218cc 100644 --- a/src/server/saved_objects/service/saved_objects_client.test.js +++ b/src/server/saved_objects/service/saved_objects_client.test.js @@ -26,9 +26,9 @@ test(`#create`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const type = 'foo'; - const attributes = {}; - const options = {}; + const type = Symbol(); + const attributes = Symbol(); + const options = Symbol(); const result = await client.create(type, attributes, options); expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options); @@ -42,8 +42,8 @@ test(`#bulkCreate`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const objects = []; - const options = {}; + const objects = Symbol(); + const options = Symbol(); const result = await client.bulkCreate(objects, options); expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options); @@ -57,11 +57,12 @@ test(`#delete`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const type = 'foo'; - const id = 1; - const result = await client.delete(type, id); + const type = Symbol(); + const id = Symbol(); + const options = Symbol(); + const result = await client.delete(type, id, options); - expect(mockRepository.delete).toHaveBeenCalledWith(type, id); + expect(mockRepository.delete).toHaveBeenCalledWith(type, id, options); expect(result).toBe(returnValue); }); @@ -72,7 +73,7 @@ test(`#find`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const options = { type: 'foo' }; + const options = Symbol(); const result = await client.find(options); expect(mockRepository.find).toHaveBeenCalledWith(options); @@ -86,10 +87,11 @@ test(`#bulkGet`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const objects = {}; - const result = await client.bulkGet(objects); + const objects = Symbol(); + const options = Symbol(); + const result = await client.bulkGet(objects, options); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); expect(result).toBe(returnValue); }); @@ -100,11 +102,12 @@ test(`#get`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const type = 'foo'; - const id = 1; - const result = await client.get(type, id); + const type = Symbol(); + const id = Symbol(); + const options = Symbol(); + const result = await client.get(type, id, options); - expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); expect(result).toBe(returnValue); }); @@ -115,10 +118,10 @@ test(`#update`, async () => { }; const client = new SavedObjectsClient(mockRepository); - const type = 'foo'; - const id = 1; - const attributes = {}; - const options = {}; + const type = Symbol(); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); const result = await client.update(type, id, attributes, options); expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options); diff --git a/src/ui/ui_exports/__tests__/collect_ui_exports.js b/src/ui/ui_exports/__tests__/collect_ui_exports.js index 2a7464e58c8d0..58f55cdf6915b 100644 --- a/src/ui/ui_exports/__tests__/collect_ui_exports.js +++ b/src/ui/ui_exports/__tests__/collect_ui_exports.js @@ -38,7 +38,12 @@ const specs = new PluginPack({ 'plugin/test/visType1', 'plugin/test/visType2', 'plugin/test/visType3', - ] + ], + savedObjectSchemas: { + foo: { + isNamespaceAgnostic: true + } + } } }), new Plugin({ @@ -48,7 +53,12 @@ const specs = new PluginPack({ 'plugin/test2/visType1', 'plugin/test2/visType2', 'plugin/test2/visType3', - ] + ], + savedObjectSchemas: { + bar: { + isNamespaceAgnostic: true + } + } } }), ]; @@ -70,6 +80,15 @@ describe('plugin discovery', () => { 'plugin/test2/visType2', 'plugin/test2/visType3' ]); + + expect(uiExports.savedObjectSchemas).to.eql({ + foo: { + isNamespaceAgnostic: true + }, + bar: { + isNamespaceAgnostic: true + }, + }); }); }); }); diff --git a/src/ui/ui_exports/ui_export_types/index.js b/src/ui/ui_exports/ui_export_types/index.js index ed350cefd23ad..9eb295bee7546 100644 --- a/src/ui/ui_exports/ui_export_types/index.js +++ b/src/ui/ui_exports/ui_export_types/index.js @@ -25,6 +25,7 @@ export { export { mappings, migrations, + savedObjectSchemas, validations, } from './saved_object'; diff --git a/src/ui/ui_exports/ui_export_types/saved_object.js b/src/ui/ui_exports/ui_export_types/saved_object.js index 9e06ae7fc757e..4b0ab0eefea39 100644 --- a/src/ui/ui_exports/ui_export_types/saved_object.js +++ b/src/ui/ui_exports/ui_export_types/saved_object.js @@ -35,6 +35,8 @@ export const mappings = wrap( // See saved_objects/migrations for more details. export const migrations = wrap(alias('savedObjectMigrations'), uniqueKeys(), mergeAtType); +export const savedObjectSchemas = wrap(uniqueKeys(), mergeAtType); + // Combines the `validations` property of each plugin, // ensuring that properties are unique across plugins. // See saved_objects/validation for more details. diff --git a/test/api_integration/apis/saved_objects/migrations.js b/test/api_integration/apis/saved_objects/migrations.js index 5c46e3c09a217..540b5d0741633 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.js @@ -27,6 +27,8 @@ import { DocumentMigrator, IndexMigrator, } from '../../../../src/server/saved_objects/migrations/core'; +import { SavedObjectsSerializer } from '../../../../src/server/saved_objects/serialization'; +import { SavedObjectsSchema } from '../../../../src/server/saved_objects/schema'; export default ({ getService }) => { const es = getService('es'); @@ -248,6 +250,7 @@ async function migrateIndex({ callCluster, index, migrations, mappingProperties, mappingProperties, pollInterval: 50, scrollDuration: '5m', + serializer: new SavedObjectsSerializer(new SavedObjectsSchema()) }); return await migrator.migrate(); diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index ca66742370ae2..df6052dc4bb3d 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -45,12 +45,12 @@ export class SecureSavedObjectsClient { ); } - async delete(type, id) { + async delete(type, id, options = {}) { return await this._execute( type, 'delete', - { type, id }, - repository => repository.delete(type, id), + { type, id, options }, + repository => repository.delete(type, id, options), ); } @@ -63,22 +63,22 @@ export class SecureSavedObjectsClient { ); } - async bulkGet(objects = []) { + async bulkGet(objects = [], options = {}) { const types = uniq(objects.map(o => o.type)); return await this._execute( types, 'bulk_get', - { objects }, - repository => repository.bulkGet(objects) + { objects, options }, + repository => repository.bulkGet(objects, options) ); } - async get(type, id) { + async get(type, id, options = {}) { return await this._execute( type, 'get', - { type, id }, - repository => repository.get(type, id) + { type, id, options }, + repository => repository.get(type, id, options) ); } diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js index 61a0c0a50bcdb..7be9d4358b06d 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -353,8 +353,9 @@ describe('#delete', () => { actions: mockActions, }); const id = Symbol(); + const options = Symbol(); - await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + await expect(client.delete(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); @@ -366,6 +367,7 @@ describe('#delete', () => { { type, id, + options, } ); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -390,15 +392,17 @@ describe('#delete', () => { actions: createMockActions(), }); const id = Symbol(); + const options = Symbol(); - const result = await client.delete(type, id); + const result = await client.delete(type, id, options); expect(result).toBe(returnValue); - expect(mockRepository.delete).toHaveBeenCalledWith(type, id); + expect(mockRepository.delete).toHaveBeenCalledWith(type, id, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { type, id, + options, }); }); @@ -422,11 +426,12 @@ describe('#delete', () => { actions: createMockActions(), }); const id = Symbol(); + const options = Symbol(); - const result = await client.delete(type, id); + const result = await client.delete(type, id, options); expect(result).toBe(returnValue); - expect(mockRepository.delete).toHaveBeenCalledWith(type, id); + expect(mockRepository.delete).toHaveBeenCalledWith(type, id, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); @@ -675,8 +680,9 @@ describe('#bulkGet', () => { { type: type1 }, { type: type2 }, ]; + const options = Symbol(); - await expect(client.bulkGet(objects)).rejects.toThrowError(mockErrors.forbiddenError); + await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); expect(mockCheckPrivileges).toHaveBeenCalledWith([ mockActions.getSavedObjectAction(type1, 'bulk_get'), @@ -689,7 +695,8 @@ describe('#bulkGet', () => { [type1, type2], [mockActions.getSavedObjectAction(type1, 'bulk_get')], { - objects + objects, + options, } ); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -718,14 +725,16 @@ describe('#bulkGet', () => { { type: type1, id: 'foo-id' }, { type: type2, id: 'bar-id' }, ]; + const options = Symbol(); - const result = await client.bulkGet(objects); + const result = await client.bulkGet(objects, options); expect(result).toBe(returnValue); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { objects, + options, }); }); @@ -753,11 +762,12 @@ describe('#bulkGet', () => { { type: type1, id: 'foo-id' }, { type: type2, id: 'bar-id' }, ]; + const options = Symbol(); - const result = await client.bulkGet(objects); + const result = await client.bulkGet(objects, options); expect(result).toBe(returnValue); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); @@ -805,8 +815,9 @@ describe('#get', () => { actions: mockActions, }); const id = Symbol(); + const options = Symbol(); - await expect(client.get(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); @@ -818,6 +829,7 @@ describe('#get', () => { { type, id, + options, } ); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -842,15 +854,17 @@ describe('#get', () => { actions: createMockActions(), }); const id = Symbol(); + const options = Symbol(); - const result = await client.get(type, id); + const result = await client.get(type, id, options); expect(result).toBe(returnValue); - expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { type, id, + options, }); }); @@ -874,11 +888,12 @@ describe('#get', () => { actions: createMockActions(), }); const id = Symbol(); + const options = Symbol(); - const result = await client.get(type, id); + const result = await client.get(type, id, options); expect(result).toBe(returnValue); - expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); });