From 25f16db4d9791a8943b386cccb8680d70bc6bf54 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 20 Jan 2021 17:39:21 -0500 Subject: [PATCH] Sharing saved objects, phase 2 (#80945) --- docs/api/saved-objects.asciidoc | 3 + docs/api/saved-objects/get.asciidoc | 2 +- docs/api/saved-objects/resolve.asciidoc | 130 ++ ...public.savedobject.coremigrationversion.md | 13 + .../kibana-plugin-core-public.savedobject.md | 1 + ...jectscreateoptions.coremigrationversion.md | 13 + ...n-core-public.savedobjectscreateoptions.md | 1 + ...-public.simplesavedobject._constructor_.md | 4 +- ....simplesavedobject.coremigrationversion.md | 11 + ...na-plugin-core-public.simplesavedobject.md | 3 +- .../core/server/kibana-plugin-core-server.md | 4 +- ...server.savedobject.coremigrationversion.md | 13 + .../kibana-plugin-core-server.savedobject.md | 1 + ...gin-core-server.savedobjectmigrationmap.md | 2 +- ...tsbulkcreateobject.coremigrationversion.md | 18 + ...ore-server.savedobjectsbulkcreateobject.md | 1 + ...a-plugin-core-server.savedobjectsclient.md | 1 + ...-core-server.savedobjectsclient.resolve.md | 26 + ...jectscreateoptions.coremigrationversion.md | 18 + ...n-core-server.savedobjectscreateoptions.md | 1 + ...e-server.savedobjectsrawdocparseoptions.md | 20 + ...tsrawdocparseoptions.namespacetreatment.md | 15 + ...ugin-core-server.savedobjectsrepository.md | 1 + ...e-server.savedobjectsrepository.resolve.md | 28 + ...core-server.savedobjectsresolveresponse.md | 20 + ...ver.savedobjectsresolveresponse.outcome.md | 15 + ...avedobjectsresolveresponse.saved_object.md | 11 + ...sserializer.generaterawlegacyurlaliasid.md | 26 + ...savedobjectsserializer.israwsavedobject.md | 5 +- ...ugin-core-server.savedobjectsserializer.md | 5 +- ...savedobjectsserializer.rawtosavedobject.md | 3 +- ...type.converttomultinamespacetypeversion.md | 42 + ...ana-plugin-core-server.savedobjectstype.md | 22 + ...plugin-plugins-data-server.plugin.start.md | 4 +- docs/user/security/audit-logging.asciidoc | 4 + src/core/public/public.api.md | 6 +- .../saved_objects/saved_objects_client.ts | 2 + .../saved_objects/simple_saved_object.ts | 14 +- .../core_usage_stats_client.mock.ts | 1 + .../core_usage_stats_client.test.ts | 76 + .../core_usage_stats_client.ts | 6 + src/core/server/core_usage_data/types.ts | 7 + src/core/server/index.ts | 2 + src/core/server/saved_objects/index.ts | 1 + .../migrations/core/__mocks__/index.ts | 13 + .../build_active_mappings.test.ts.snap | 8 + .../migrations/core/build_active_mappings.ts | 3 + .../migrations/core/document_migrator.test.ts | 1665 +++++++++++------ .../migrations/core/document_migrator.ts | 551 +++++- .../migrations/core/elastic_index.test.ts | 21 +- .../migrations/core/elastic_index.ts | 32 +- .../migrations/core/index_migrator.test.ts | 27 +- .../migrations/core/index_migrator.ts | 7 +- .../migrations/core/migrate_raw_docs.test.ts | 84 +- .../migrations/core/migrate_raw_docs.ts | 23 +- .../migrations/core/migration_context.ts | 3 + .../kibana_migrator.test.ts.snap | 4 + .../migrations/kibana/kibana_migrator.ts | 4 +- .../server/saved_objects/migrations/types.ts | 2 +- .../saved_objects/object_types/constants.ts | 12 + .../saved_objects/object_types/index.ts | 11 + .../object_types/registration.test.ts | 25 + .../object_types/registration.ts | 29 + .../saved_objects/object_types/types.ts | 19 + .../saved_objects/routes/bulk_create.ts | 1 + .../server/saved_objects/routes/create.ts | 18 +- src/core/server/saved_objects/routes/index.ts | 2 + .../routes/integration_tests/resolve.test.ts | 91 + .../server/saved_objects/routes/resolve.ts | 38 + .../saved_objects_service.test.ts | 13 + .../saved_objects/saved_objects_service.ts | 3 + .../saved_objects/serialization/index.ts | 1 + .../serialization/serializer.test.ts | 215 +++ .../saved_objects/serialization/serializer.ts | 132 +- .../saved_objects/serialization/types.ts | 17 + .../service/lib/included_fields.test.ts | 4 +- .../service/lib/included_fields.ts | 1 + .../service/lib/repository.mock.ts | 1 + .../service/lib/repository.test.js | 235 ++- .../saved_objects/service/lib/repository.ts | 191 +- .../service/saved_objects_client.mock.ts | 1 + .../service/saved_objects_client.test.js | 16 + .../service/saved_objects_client.ts | 53 + src/core/server/saved_objects/types.ts | 35 + src/core/server/server.api.md | 37 +- src/core/types/saved_objects.ts | 2 + src/plugins/data/server/server.api.md | 2 +- .../collectors/core/core_usage_collector.ts | 7 + src/plugins/telemetry/schema/oss_plugins.json | 21 + .../apis/saved_objects/bulk_create.ts | 11 + .../apis/saved_objects/bulk_get.ts | 9 + .../apis/saved_objects/create.ts | 39 + .../apis/saved_objects/export.ts | 10 + .../apis/saved_objects/find.ts | 13 +- .../api_integration/apis/saved_objects/get.ts | 8 + .../apis/saved_objects/index.ts | 5 +- .../lib/saved_objects_test_utils.ts | 18 + .../apis/saved_objects/migrations.ts | 346 +++- .../apis/saved_objects/resolve.ts | 104 + .../apis/saved_objects_management/find.ts | 10 + ...ypted_saved_objects_client_wrapper.test.ts | 134 ++ .../encrypted_saved_objects_client_wrapper.ts | 13 + .../server/audit/audit_events.test.ts | 18 + .../security/server/audit/audit_events.ts | 3 + ...ecure_saved_objects_client_wrapper.test.ts | 77 + .../secure_saved_objects_client_wrapper.ts | 36 + .../spaces_saved_objects_client.test.ts | 31 + .../spaces_saved_objects_client.ts | 22 + .../saved_objects/spaces/data.json | 116 ++ .../saved_objects/spaces/mappings.json | 29 + .../saved_object_test_plugin/server/plugin.ts | 7 + .../common/suites/resolve.ts | 138 ++ .../security_and_spaces/apis/index.ts | 1 + .../security_and_spaces/apis/resolve.ts | 82 + .../security_only/apis/index.ts | 1 + .../security_only/apis/resolve.ts | 73 + .../spaces_only/apis/index.ts | 1 + .../spaces_only/apis/resolve.ts | 47 + 118 files changed, 4859 insertions(+), 825 deletions(-) create mode 100644 docs/api/saved-objects/resolve.asciidoc create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md create mode 100644 src/core/server/saved_objects/migrations/core/__mocks__/index.ts create mode 100644 src/core/server/saved_objects/object_types/constants.ts create mode 100644 src/core/server/saved_objects/object_types/index.ts create mode 100644 src/core/server/saved_objects/object_types/registration.test.ts create mode 100644 src/core/server/saved_objects/object_types/registration.ts create mode 100644 src/core/server/saved_objects/object_types/types.ts create mode 100644 src/core/server/saved_objects/routes/integration_tests/resolve.test.ts create mode 100644 src/core/server/saved_objects/routes/resolve.ts create mode 100644 test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts create mode 100644 test/api_integration/apis/saved_objects/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/common/suites/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts diff --git a/docs/api/saved-objects.asciidoc b/docs/api/saved-objects.asciidoc index 0d8ceefb47e91..ecf975134c64a 100644 --- a/docs/api/saved-objects.asciidoc +++ b/docs/api/saved-objects.asciidoc @@ -10,6 +10,8 @@ The following saved objects APIs are available: * <> to retrieve a single {kib} saved object by ID +* <> to retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists + * <> to retrieve multiple {kib} saved objects by ID * <> to retrieve a paginated set of {kib} saved objects by various conditions @@ -40,4 +42,5 @@ include::saved-objects/delete.asciidoc[] include::saved-objects/export.asciidoc[] include::saved-objects/import.asciidoc[] include::saved-objects/resolve_import_errors.asciidoc[] +include::saved-objects/resolve.asciidoc[] include::saved-objects/rotate_encryption_key.asciidoc[] diff --git a/docs/api/saved-objects/get.asciidoc b/docs/api/saved-objects/get.asciidoc index 6aad9759ef5e0..4c8cd020e0286 100644 --- a/docs/api/saved-objects/get.asciidoc +++ b/docs/api/saved-objects/get.asciidoc @@ -78,7 +78,7 @@ The API returns the following: "title": "[Flights] Global Flight Dashboard", "hits": 0, "description": "Analyze mock flight data for ES-Air, Logstash Airways, Kibana Airlines and JetBeats", - "panelsJSON": "[{\"panelIndex\":\"1\",\"gridData\":{\"x\":0,\"y\":0,\"w\":32,\"h\":7,\"i\":\"1\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_0\"},{\"panelIndex\":\"3\",\"gridData\":{\"x\":17,\"y\":7,\"w\":23,\"h\":12,\"i\":\"3\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Average Ticket Price\":\"#0A50A1\",\"Flight Count\":\"#82B5D8\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_1\"},{\"panelIndex\":\"4\",\"gridData\":{\"x\":0,\"y\":85,\"w\":48,\"h\":15,\"i\":\"4\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_2\"},{\"panelIndex\":\"5\",\"gridData\":{\"x\":0,\"y\":7,\"w\":17,\"h\":12,\"i\":\"5\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"ES-Air\":\"#447EBC\",\"JetBeats\":\"#65C5DB\",\"Kibana Airlines\":\"#BA43A9\",\"Logstash Airways\":\"#E5AC0E\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_3\"},{\"panelIndex\":\"6\",\"gridData\":{\"x\":24,\"y\":33,\"w\":24,\"h\":14,\"i\":\"6\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Carrier Delay\":\"#5195CE\",\"Late Aircraft Delay\":\"#1F78C1\",\"NAS Delay\":\"#70DBED\",\"No Delay\":\"#BADFF4\",\"Security Delay\":\"#052B51\",\"Weather Delay\":\"#6ED0E0\"}}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_4\"},{\"panelIndex\":\"7\",\"gridData\":{\"x\":24,\"y\":19,\"w\":24,\"h\":14,\"i\":\"7\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_5\"},{\"panelIndex\":\"10\",\"gridData\":{\"x\":0,\"y\":35,\"w\":24,\"h\":12,\"i\":\"10\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_6\"},{\"panelIndex\":\"13\",\"gridData\":{\"x\":10,\"y\":19,\"w\":14,\"h\":8,\"i\":\"13\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_7\"},{\"panelIndex\":\"14\",\"gridData\":{\"x\":10,\"y\":27,\"w\":14,\"h\":8,\"i\":\"14\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_8\"},{\"panelIndex\":\"18\",\"gridData\":{\"x\":24,\"y\":70,\"w\":24,\"h\":15,\"i\":\"18\"},\"embeddableConfig\":{\"mapCenter\":[27.421687059550266,15.371002131141724],\"mapZoom\":1},\"version\":\"6.3.0\",\"panelRefName\":\"panel_9\"},{\"panelIndex\":\"21\",\"gridData\":{\"x\":0,\"y\":62,\"w\":48,\"h\":8,\"i\":\"21\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_10\"},{\"panelIndex\":\"22\",\"gridData\":{\"x\":32,\"y\":0,\"w\":16,\"h\":7,\"i\":\"22\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_11\"},{\"panelIndex\":\"23\",\"gridData\":{\"x\":0,\"y\":70,\"w\":24,\"h\":15,\"i\":\"23\"},\"embeddableConfig\":{\"mapCenter\":[42.19556096274418,9.536742995308601e-7],\"mapZoom\":1},\"version\":\"6.3.0\",\"panelRefName\":\"panel_12\"},{\"panelIndex\":\"25\",\"gridData\":{\"x\":0,\"y\":19,\"w\":10,\"h\":8,\"i\":\"25\"},\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 - 50\":\"rgb(247,251,255)\",\"100 - 150\":\"rgb(107,174,214)\",\"150 - 200\":\"rgb(33,113,181)\",\"200 - 250\":\"rgb(8,48,107)\",\"50 - 100\":\"rgb(198,219,239)\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_13\"},{\"panelIndex\":\"27\",\"gridData\":{\"x\":0,\"y\":27,\"w\":10,\"h\":8,\"i\":\"27\"},\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 - 50\":\"rgb(247,251,255)\",\"100 - 150\":\"rgb(107,174,214)\",\"150 - 200\":\"rgb(33,113,181)\",\"200 - 250\":\"rgb(8,48,107)\",\"50 - 100\":\"rgb(198,219,239)\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_14\"},{\"panelIndex\":\"28\",\"gridData\":{\"x\":0,\"y\":47,\"w\":24,\"h\":15,\"i\":\"28\"},\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 -* Connection #0 to host 69c72adb58fa46c69a01afdf4a6cbfd3.us-west1.gcp.cloud.es.io left intact\n 11\":\"rgb(247,251,255)\",\"11 - 22\":\"rgb(208,225,242)\",\"22 - 33\":\"rgb(148,196,223)\",\"33 - 44\":\"rgb(74,152,201)\",\"44 - 55\":\"rgb(23,100,171)\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_15\"},{\"panelIndex\":\"29\",\"gridData\":{\"x\":40,\"y\":7,\"w\":8,\"h\":6,\"i\":\"29\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_16\"},{\"panelIndex\":\"30\",\"gridData\":{\"x\":40,\"y\":13,\"w\":8,\"h\":6,\"i\":\"30\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_17\"},{\"panelIndex\":\"31\",\"gridData\":{\"x\":24,\"y\":47,\"w\":24,\"h\":15,\"i\":\"31\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_18\"}]", + "panelsJSON": "[ . . . ]", "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", "version": 1, "timeRestore": true, diff --git a/docs/api/saved-objects/resolve.asciidoc b/docs/api/saved-objects/resolve.asciidoc new file mode 100644 index 0000000000000..f2bf31bc5d9e4 --- /dev/null +++ b/docs/api/saved-objects/resolve.asciidoc @@ -0,0 +1,130 @@ +[[saved-objects-api-resolve]] +=== Resolve object API +++++ +Resolve object +++++ + +experimental[] Retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists. + +Under certain circumstances, when Kibana is upgraded, saved object migrations may necessitate regenerating some object IDs to enable new +features. When an object's ID is regenerated, a legacy URL alias is created for that object, preserving its old ID. In such a scenario, that +object can be retrieved via the Resolve API using either its new ID or its old ID. + +[[saved-objects-api-resolve-request]] +==== Request + +`GET :/api/saved_objects/resolve//` + +`GET :/s//api/saved_objects/resolve//` + +[[saved-objects-api-resolve-params]] +==== Path parameters + +`space_id`:: + (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + + +`type`:: + (Required, string) Valid options include `visualization`, `dashboard`, `search`, `index-pattern`, `config`, and `timelion-sheet`. + +`id`:: + (Required, string) The ID of the object to retrieve. + +[[saved-objects-api-resolve-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[saved-objects-api-resolve-example]] +==== Example + +Retrieve the index pattern object with the `my-pattern` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/saved_objects/resolve/index-pattern/my-pattern +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "saved_object": { + "id": "my-pattern", + "type": "index-pattern", + "version": 1, + "attributes": { + "title": "my-pattern-*" + } + }, + "outcome": "exactMatch" +} +-------------------------------------------------- + +The `outcome` field may be any of the following: + +* `"exactMatch"` -- One document exactly matched the given ID. +* `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. +* `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +Retrieve a dashboard object in the `testspace` by ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET s/testspace/api/saved_objects/resolve/dashboard/7adfa750-4c81-11e8-b3d7-01146121b73d +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "saved_object": { + "id": "7adfa750-4c81-11e8-b3d7-01146121b73d", + "type": "dashboard", + "updated_at": "2019-07-23T00:11:07.059Z", + "version": "WzQ0LDFd", + "attributes": { + "title": "[Flights] Global Flight Dashboard", + "hits": 0, + "description": "Analyze mock flight data for ES-Air, Logstash Airways, Kibana Airlines and JetBeats", + "panelsJSON": "[ . . . ]", + "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", + "version": 1, + "timeRestore": true, + "timeTo": "now", + "timeFrom": "now-24h", + "refreshInterval": { + "display": "15 minutes", + "pause": false, + "section": 2, + "value": 900000 + }, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + }, + "references": [ + { + "name": "panel_0", + "type": "visualization", + "id": "aeb212e0-4c84-11e8-b3d7-01146121b73d" + }, + . . . + { + "name": "panel_18", + "type": "visualization", + "id": "ed78a660-53a0-11e8-acbd-0be0ad9d822b" + } + ], + "migrationVersion": { + "dashboard": "7.0.0" + } + }, + "outcome": "conflict" +} +-------------------------------------------------- diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md new file mode 100644 index 0000000000000..9060a5d6777fe --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md) + +## SavedObject.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index eb6059747426d..9404927f94957 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -15,6 +15,7 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-public.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | +| [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md new file mode 100644 index 0000000000000..3c1d068f458bc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) > [coreMigrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md) + +## SavedObjectsCreateOptions.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md index b1b93407d4ff1..a039b9f5b4fe4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md @@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions | Property | Type | Description | | --- | --- | --- | +| [coreMigrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [id](./kibana-plugin-core-public.savedobjectscreateoptions.id.md) | string | (Not recommended) Specify an id instead of having the saved objects service generate one for you. | | [migrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [overwrite](./kibana-plugin-core-public.savedobjectscreateoptions.overwrite.md) | boolean | If a document with the given id already exists, overwrite it's contents (default=false). | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md index b1a4357cca7ad..8fb005421e870 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class Signature: ```typescript -constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType); +constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObjectType); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes, | Parameter | Type | Description | | --- | --- | --- | | client | SavedObjectsClientContract | | -| { id, type, version, attributes, error, references, migrationVersion } | SavedObjectType<T> | | +| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, } | SavedObjectType<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md new file mode 100644 index 0000000000000..8e2217fab6eee --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) > [coreMigrationVersion](./kibana-plugin-core-public.simplesavedobject.coremigrationversion.md) + +## SimpleSavedObject.coreMigrationVersion property + +Signature: + +```typescript +coreMigrationVersion: SavedObjectType['coreMigrationVersion']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md index e9987f6d5bebb..35264a3a4cf0c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md @@ -18,7 +18,7 @@ export declare class SimpleSavedObject | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | +| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | ## Properties @@ -26,6 +26,7 @@ export declare class SimpleSavedObject | --- | --- | --- | --- | | [\_version](./kibana-plugin-core-public.simplesavedobject._version.md) | | SavedObjectType<T>['version'] | | | [attributes](./kibana-plugin-core-public.simplesavedobject.attributes.md) | | T | | +| [coreMigrationVersion](./kibana-plugin-core-public.simplesavedobject.coremigrationversion.md) | | SavedObjectType<T>['coreMigrationVersion'] | | | [error](./kibana-plugin-core-public.simplesavedobject.error.md) | | SavedObjectType<T>['error'] | | | [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | SavedObjectType<T>['id'] | | | [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | SavedObjectType<T>['migrationVersion'] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 7daf5d086d9e4..4c6116540c12d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -139,7 +139,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributes](./kibana-plugin-core-server.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | | [SavedObjectExportBaseOptions](./kibana-plugin-core-server.savedobjectexportbaseoptions.md) | | | [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) | Migration context provided when invoking a [migration handler](./kibana-plugin-core-server.savedobjectmigrationfn.md) | -| [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions.For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. | +| [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions, and they cannot exceed the current Kibana version.For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. | | [SavedObjectReference](./kibana-plugin-core-server.savedobjectreference.md) | A reference to another saved object. | | [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) | | | [SavedObjectsAddToNamespacesResponse](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md) | | @@ -187,10 +187,12 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsMigrationLogger](./kibana-plugin-core-server.savedobjectsmigrationlogger.md) | | | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [SavedObjectsRawDoc](./kibana-plugin-core-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. | +| [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) | Options that can be specified when using the saved objects serializer to parse a raw document. | | [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) | | | [SavedObjectsRemoveReferencesToResponse](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md) | | | [SavedObjectsRepositoryFactory](./kibana-plugin-core-server.savedobjectsrepositoryfactory.md) | Factory provided when invoking a [client factory provider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) See [SavedObjectsServiceSetup.setClientFactoryProvider](./kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | | [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) | Options to control the "resolve import" operation. | +| [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) | | | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | Saved Objects is Kibana's data persistence mechanism allowing plugins to use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods for registering Saved Object types, creating and registering Saved Object client wrappers and factories. | | [SavedObjectsServiceStart](./kibana-plugin-core-server.savedobjectsservicestart.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing and querying state. The SavedObjectsServiceStart API provides a scoped Saved Objects client for interacting with Saved Objects. | | [SavedObjectStatusMeta](./kibana-plugin-core-server.savedobjectstatusmeta.md) | Meta information about the SavedObjectService's status. Available to plugins via [CoreSetup.status](./kibana-plugin-core-server.coresetup.status.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md new file mode 100644 index 0000000000000..b4d1f3c769451 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md) + +## SavedObject.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 5aefc55736cd1..07172487e6fde 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -15,6 +15,7 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | +| [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md index 2ab9fcaf428b9..c07a41e28d45b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md @@ -4,7 +4,7 @@ ## SavedObjectMigrationMap interface -A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions. +A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions, and they cannot exceed the current Kibana version. For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md new file mode 100644 index 0000000000000..fb1f485cdf202 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) + +## SavedObjectsBulkCreateObject.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` + +## Remarks + +Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion` field set and you want to create it again. + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 5ac5f6d9807bd..6fc01212a2e41 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -16,6 +16,7 @@ export interface SavedObjectsBulkCreateObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | +| [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | | [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 7fb34631c736e..da1f4d029ea2b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -36,5 +36,6 @@ The constructor for this class is marked as internal. Third-party code should no | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | +| [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md new file mode 100644 index 0000000000000..b9a63f0b8c05a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [resolve](./kibana-plugin-core-server.savedobjectsclient.resolve.md) + +## SavedObjectsClient.resolve() method + +Resolves a single object, using any legacy URL alias if it exists + +Signature: + +```typescript +resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | The type of SavedObject to retrieve | +| id | string | The ID of the SavedObject to retrieve | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md new file mode 100644 index 0000000000000..e2a4064ec4f33 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) + +## SavedObjectsCreateOptions.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` + +## Remarks + +Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion` field set and you want to create it again. + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index e6d306784f8ae..1805f389d4e7f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | +| [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | | [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md new file mode 100644 index 0000000000000..708d1bc9c514d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) + +## SavedObjectsRawDocParseOptions interface + +Options that can be specified when using the saved objects serializer to parse a raw document. + +Signature: + +```typescript +export interface SavedObjectsRawDocParseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [namespaceTreatment](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md) | 'strict' | 'lax' | Optional setting to allow for lax handling of the raw document ID and namespace field. This is needed when a previously single-namespace object type is converted to a multi-namespace object type, and it is only intended to be used during upgrade migrations.If not specified, the default treatment is strict. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md new file mode 100644 index 0000000000000..c315d78aaf417 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) > [namespaceTreatment](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md) + +## SavedObjectsRawDocParseOptions.namespaceTreatment property + +Optional setting to allow for lax handling of the raw document ID and namespace field. This is needed when a previously single-namespace object type is converted to a multi-namespace object type, and it is only intended to be used during upgrade migrations. + +If not specified, the default treatment is `strict`. + +Signature: + +```typescript +namespaceTreatment?: 'strict' | 'lax'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index c7e5b0476bad4..4d13fea12572c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -28,5 +28,6 @@ export declare class SavedObjectsRepository | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | +| [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md new file mode 100644 index 0000000000000..7d0a1c7d204be --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [resolve](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) + +## SavedObjectsRepository.resolve() method + +Resolves a single object, using any legacy URL alias if it exists + +Signature: + +```typescript +resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise>` + +{promise} - { saved\_object, outcome } + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md new file mode 100644 index 0000000000000..cfb309da0a716 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) + +## SavedObjectsResolveResponse interface + + +Signature: + +```typescript +export interface SavedObjectsResolveResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | +| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md new file mode 100644 index 0000000000000..eadd85b175375 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) + +## SavedObjectsResolveResponse.outcome property + +The outcome for a successful `resolve` call is one of the following values: + +\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +Signature: + +```typescript +outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md new file mode 100644 index 0000000000000..c184312675f75 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) + +## SavedObjectsResolveResponse.saved\_object property + +Signature: + +```typescript +saved_object: SavedObject; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md new file mode 100644 index 0000000000000..d33f42ee2cf5f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsSerializer](./kibana-plugin-core-server.savedobjectsserializer.md) > [generateRawLegacyUrlAliasId](./kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md) + +## SavedObjectsSerializer.generateRawLegacyUrlAliasId() method + +Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias. + +Signature: + +```typescript +generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| namespace | string | | +| type | string | | +| id | string | | + +Returns: + +`string` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md index b9033b00624cc..1094cc25ab557 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md @@ -9,14 +9,15 @@ Determines whether or not the raw document can be converted to a saved object. Signature: ```typescript -isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean; +isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| rawDoc | SavedObjectsRawDoc | | +| doc | SavedObjectsRawDoc | | +| options | SavedObjectsRawDocParseOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md index 129e6d8bf90f8..c7fa5fc85c613 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md @@ -23,7 +23,8 @@ The constructor for this class is marked as internal. Third-party code should no | Method | Modifiers | Description | | --- | --- | --- | | [generateRawId(namespace, type, id)](./kibana-plugin-core-server.savedobjectsserializer.generaterawid.md) | | Given a saved object type and id, generates the compound id that is stored in the raw document. | -| [isRawSavedObject(rawDoc)](./kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md) | | Determines whether or not the raw document can be converted to a saved object. | -| [rawToSavedObject(doc)](./kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md) | | Converts a document from the format that is stored in elasticsearch to the saved object client format. | +| [generateRawLegacyUrlAliasId(namespace, type, id)](./kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md) | | Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias. | +| [isRawSavedObject(doc, options)](./kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md) | | Determines whether or not the raw document can be converted to a saved object. | +| [rawToSavedObject(doc, options)](./kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md) | | Converts a document from the format that is stored in elasticsearch to the saved object client format. | | [savedObjectToRaw(savedObj)](./kibana-plugin-core-server.savedobjectsserializer.savedobjecttoraw.md) | | Converts a document from the saved object client format to the format that is stored in elasticsearch. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md index dc9a2ef85839f..3fc386f263141 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md @@ -9,7 +9,7 @@ Converts a document from the format that is stored in elasticsearch to the saved Signature: ```typescript -rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; +rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; ``` ## Parameters @@ -17,6 +17,7 @@ rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; | Parameter | Type | Description | | --- | --- | --- | | doc | SavedObjectsRawDoc | | +| options | SavedObjectsRawDocParseOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md new file mode 100644 index 0000000000000..064bd0b35699d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -0,0 +1,42 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) + +## SavedObjectsType.convertToMultiNamespaceTypeVersion property + +If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + +Requirements: + +1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) + +Example of a single-namespace type in 7.10: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'single', + mappings: {...} +} + +``` +Example after converting to a multi-namespace type in 7.11: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '7.11.0' +} + +``` +Note: a migration function can be optionally specified for the same version. + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index e5c3fa2b3e92d..eacad53be39fe 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -19,6 +19,28 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.10: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'single', + mappings: {...} +} + +``` +Example after converting to a multi-namespace type in 7.11: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '7.11.0' +} + +``` +Note: a migration function can be optionally specified for the same version. | | [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index 88f85eb7a7d05..8f1ea7b95a5f9 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("src/core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("src/core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }` diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index acb0f94cf878c..12a87b1422c5c 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -194,6 +194,10 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a saved object. | `failure` | User is not authorized to access a saved object. +.2+| `saved_object_resolve` +| `success` | User has accessed a saved object. +| `failure` | User is not authorized to access a saved object. + .2+| `saved_object_find` | `success` | User has accessed a saved object as part of a search operation. | `failure` | User is not authorized to search for saved objects. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index da818470133cd..0a166d4511c5f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1027,6 +1027,7 @@ export type PublicUiSettingsParams = Omit; // @public (undocumented) export interface SavedObject { attributes: T; + coreMigrationVersion?: string; // (undocumented) error?: SavedObjectError; id: string; @@ -1144,6 +1145,7 @@ export type SavedObjectsClientContract = PublicMethodsOf; // @public (undocumented) export interface SavedObjectsCreateOptions { + coreMigrationVersion?: string; id?: string; migrationVersion?: SavedObjectsMigrationVersion; overwrite?: boolean; @@ -1377,10 +1379,12 @@ export class ScopedHistory implements History { - constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObject); + constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObject); // (undocumented) attributes: T; // (undocumented) + coreMigrationVersion: SavedObject['coreMigrationVersion']; + // (undocumented) delete(): Promise<{}>; // (undocumented) error: SavedObject['error']; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6c24cf2d0971b..fdef63c392db6 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -38,6 +38,8 @@ export interface SavedObjectsCreateOptions { overwrite?: boolean; /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** A semver value that is used when upgrading objects between Kibana versions. */ + coreMigrationVersion?: string; references?: SavedObjectReference[]; } diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index a0ebc8214aaec..0eb0e0b53f78e 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -27,12 +27,22 @@ export class SimpleSavedObject { public id: SavedObjectType['id']; public type: SavedObjectType['type']; public migrationVersion: SavedObjectType['migrationVersion']; + public coreMigrationVersion: SavedObjectType['coreMigrationVersion']; public error: SavedObjectType['error']; public references: SavedObjectType['references']; constructor( private client: SavedObjectsClientContract, - { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType + { + id, + type, + version, + attributes, + error, + references, + migrationVersion, + coreMigrationVersion, + }: SavedObjectType ) { this.id = id; this.type = type; @@ -40,6 +50,7 @@ export class SimpleSavedObject { this.references = references || []; this._version = version; this.migrationVersion = migrationVersion; + this.coreMigrationVersion = coreMigrationVersion; if (error) { this.error = error; } @@ -66,6 +77,7 @@ export class SimpleSavedObject { } else { return this.client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion, + coreMigrationVersion: this.coreMigrationVersion, references: this.references, }); } diff --git a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts index 8495f2e0d082a..8a0aaa646438d 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts @@ -18,6 +18,7 @@ const createUsageStatsClientMock = () => incrementSavedObjectsDelete: jest.fn().mockResolvedValue(null), incrementSavedObjectsFind: jest.fn().mockResolvedValue(null), incrementSavedObjectsGet: jest.fn().mockResolvedValue(null), + incrementSavedObjectsResolve: jest.fn().mockResolvedValue(null), incrementSavedObjectsUpdate: jest.fn().mockResolvedValue(null), incrementSavedObjectsImport: jest.fn().mockResolvedValue(null), incrementSavedObjectsResolveImportErrors: jest.fn().mockResolvedValue(null), diff --git a/src/core/server/core_usage_data/core_usage_stats_client.test.ts b/src/core/server/core_usage_data/core_usage_stats_client.test.ts index 0e43363dddb77..2067466c63510 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.test.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.test.ts @@ -20,6 +20,7 @@ import { DELETE_STATS_PREFIX, FIND_STATS_PREFIX, GET_STATS_PREFIX, + RESOLVE_STATS_PREFIX, UPDATE_STATS_PREFIX, IMPORT_STATS_PREFIX, RESOLVE_IMPORT_STATS_PREFIX, @@ -594,6 +595,81 @@ describe('CoreUsageStatsClient', () => { }); }); + describe('#incrementSavedObjectsResolve', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + const request = httpServerMock.createKibanaRequest(); + await expect( + usageStatsClient.incrementSavedObjectsResolve({ + request, + } as BaseIncrementOptions) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalled(); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + const request = httpServerMock.createKibanaRequest(); + await usageStatsClient.incrementSavedObjectsResolve({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${RESOLVE_STATS_PREFIX}.total`, + `${RESOLVE_STATS_PREFIX}.namespace.default.total`, + `${RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options and the default namespace string appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(DEFAULT_NAMESPACE_STRING); + + const request = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders }); + await usageStatsClient.incrementSavedObjectsResolve({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${RESOLVE_STATS_PREFIX}.total`, + `${RESOLVE_STATS_PREFIX}.namespace.default.total`, + `${RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.yes`, + ], + incrementOptions + ); + }); + + it('handles a non-default space appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup('foo'); + + const request = httpServerMock.createKibanaRequest(); + await usageStatsClient.incrementSavedObjectsResolve({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${RESOLVE_STATS_PREFIX}.total`, + `${RESOLVE_STATS_PREFIX}.namespace.custom.total`, + `${RESOLVE_STATS_PREFIX}.namespace.custom.kibanaRequest.no`, + ], + incrementOptions + ); + }); + }); + describe('#incrementSavedObjectsUpdate', () => { it('does not throw an error if repository incrementCounter operation fails', async () => { const { usageStatsClient, repositoryMock } = setup(); diff --git a/src/core/server/core_usage_data/core_usage_stats_client.ts b/src/core/server/core_usage_data/core_usage_stats_client.ts index 103e98d2ef37e..70bdb99f666fd 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.ts @@ -40,6 +40,7 @@ export const CREATE_STATS_PREFIX = 'apiCalls.savedObjectsCreate'; export const DELETE_STATS_PREFIX = 'apiCalls.savedObjectsDelete'; export const FIND_STATS_PREFIX = 'apiCalls.savedObjectsFind'; export const GET_STATS_PREFIX = 'apiCalls.savedObjectsGet'; +export const RESOLVE_STATS_PREFIX = 'apiCalls.savedObjectsResolve'; export const UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsUpdate'; export const IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsImport'; export const RESOLVE_IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsResolveImportErrors'; @@ -53,6 +54,7 @@ const ALL_COUNTER_FIELDS = [ ...getFieldsForCounter(DELETE_STATS_PREFIX), ...getFieldsForCounter(FIND_STATS_PREFIX), ...getFieldsForCounter(GET_STATS_PREFIX), + ...getFieldsForCounter(RESOLVE_STATS_PREFIX), ...getFieldsForCounter(UPDATE_STATS_PREFIX), // Saved Objects Management APIs ...getFieldsForCounter(IMPORT_STATS_PREFIX), @@ -123,6 +125,10 @@ export class CoreUsageStatsClient { await this.updateUsageStats([], GET_STATS_PREFIX, options); } + public async incrementSavedObjectsResolve(options: BaseIncrementOptions) { + await this.updateUsageStats([], RESOLVE_STATS_PREFIX, options); + } + public async incrementSavedObjectsUpdate(options: BaseIncrementOptions) { await this.updateUsageStats([], UPDATE_STATS_PREFIX, options); } diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts index bd79e118c4460..505dd8528e755 100644 --- a/src/core/server/core_usage_data/types.ts +++ b/src/core/server/core_usage_data/types.ts @@ -66,6 +66,13 @@ export interface CoreUsageStats { 'apiCalls.savedObjectsGet.namespace.custom.total'?: number; 'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.yes'?: number; 'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsResolve.total'?: number; + 'apiCalls.savedObjectsResolve.namespace.default.total'?: number; + 'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsResolve.namespace.custom.total'?: number; + 'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no'?: number; 'apiCalls.savedObjectsUpdate.total'?: number; 'apiCalls.savedObjectsUpdate.namespace.default.total'?: number; 'apiCalls.savedObjectsUpdate.namespace.default.kibanaRequest.yes'?: number; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 0eb246b4c978b..a27863a458f2b 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -277,10 +277,12 @@ export { SavedObjectMigrationContext, SavedObjectsMigrationLogger, SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, SavedObjectUnsanitizedDoc, SavedObjectsRepositoryFactory, SavedObjectsResolveImportErrorsOptions, + SavedObjectsResolveResponse, SavedObjectsSerializer, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 57dee5cd51f1d..86ee7de5fab54 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -43,6 +43,7 @@ export { export { SavedObjectsSerializer, SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, SavedObjectUnsanitizedDoc, } from './serialization'; diff --git a/src/core/server/saved_objects/migrations/core/__mocks__/index.ts b/src/core/server/saved_objects/migrations/core/__mocks__/index.ts new file mode 100644 index 0000000000000..b22ad0c93b234 --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/__mocks__/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const mockUuidv5 = jest.fn().mockReturnValue('uuidv5'); +Object.defineProperty(mockUuidv5, 'DNS', { value: 'DNSUUID', writable: false }); +jest.mock('uuid/v5', () => mockUuidv5); + +export { mockUuidv5 }; diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index f8ef47cae8944..9ee998118bde6 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -6,6 +6,7 @@ Object { "migrationMappingPropertyHashes": Object { "aaa": "625b32086eb1d1203564cf85062dd22e", "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", @@ -23,6 +24,9 @@ Object { "bbb": Object { "type": "long", }, + "coreMigrationVersion": Object { + "type": "keyword", + }, "migrationVersion": Object { "dynamic": "true", "type": "object", @@ -64,6 +68,7 @@ exports[`buildActiveMappings handles the \`dynamic\` property of types 1`] = ` Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", @@ -78,6 +83,9 @@ Object { }, "dynamic": "strict", "properties": Object { + "coreMigrationVersion": Object { + "type": "keyword", + }, "firstType": Object { "dynamic": "strict", "properties": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 594c6e4e3df6a..83e7b1549bc97 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -153,6 +153,9 @@ function defaultMapping(): IndexMapping { }, }, }, + coreMigrationVersion: { + type: 'keyword', + }, }, }; } diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 9b97867bf187f..741f715ba6ebe 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -6,6 +6,7 @@ * Public License, v 1. */ +import { mockUuidv5 } from './__mocks__'; import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; import { SavedObjectUnsanitizedDoc } from '../../serialization'; @@ -13,9 +14,11 @@ import { DocumentMigrator } from './document_migrator'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectsType } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; const mockLoggerFactory = loggingSystemMock.create(); const mockLogger = mockLoggerFactory.get('mock logger'); +const kibanaVersion = '25.2.3'; const createRegistry = (...types: Array>) => { const registry = new SavedObjectTypeRegistry(); @@ -32,644 +35,1216 @@ const createRegistry = (...types: Array>) => { return registry; }; +beforeEach(() => { + mockUuidv5.mockClear(); +}); + describe('DocumentMigrator', () => { function testOpts() { return { - kibanaVersion: '25.2.3', + kibanaVersion, typeRegistry: createRegistry(), + minimumConvertVersion: '0.0.0', // no minimum version unless we specify it for a test case log: mockLogger, }; } - const createDefinition = (migrations: any) => ({ - kibanaVersion: '3.2.3', - typeRegistry: createRegistry({ - name: 'foo', - migrations: migrations as any, - }), - log: mockLogger, - }); + describe('validation', () => { + const createDefinition = (migrations: any) => ({ + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + migrations: migrations as any, + }), + log: mockLogger, + }); - it('validates migration definition', () => { - expect(() => new DocumentMigrator(createDefinition(() => {}))).not.toThrow(); - expect(() => new DocumentMigrator(createDefinition({}))).not.toThrow(); - expect(() => new DocumentMigrator(createDefinition(123))).toThrow( - /Migration for type foo should be an object or a function/i - ); - }); + describe('#prepareMigrations', () => { + it('validates individual migration definitions', () => { + const invalidMigrator = new DocumentMigrator(createDefinition(() => 123)); + const voidMigrator = new DocumentMigrator(createDefinition(() => {})); + const emptyObjectMigrator = new DocumentMigrator(createDefinition(() => ({}))); - describe('#prepareMigrations', () => { - it('validates individual migration definitions', () => { - const invalidMigrator = new DocumentMigrator(createDefinition(() => 123)); - const voidMigrator = new DocumentMigrator(createDefinition(() => {})); - const emptyObjectMigrator = new DocumentMigrator(createDefinition(() => ({}))); + expect(invalidMigrator.prepareMigrations).toThrow( + /Migrations map for type foo should be an object/i + ); + expect(voidMigrator.prepareMigrations).not.toThrow(); + expect(emptyObjectMigrator.prepareMigrations).not.toThrow(); + }); - expect(invalidMigrator.prepareMigrations).toThrow( - /Migrations map for type foo should be an object/i - ); - expect(voidMigrator.prepareMigrations).not.toThrow(); - expect(emptyObjectMigrator.prepareMigrations).not.toThrow(); - }); + it('validates individual migrations are valid semvers', () => { + const withInvalidVersion = { + bar: (doc: any) => doc, + '1.2.3': (doc: any) => doc, + }; + const migrationFn = new DocumentMigrator(createDefinition(() => withInvalidVersion)); + const migrationObj = new DocumentMigrator(createDefinition(withInvalidVersion)); - it('validates individual migration semvers', () => { - const withInvalidVersion = { - bar: (doc: any) => doc, - '1.2.3': (doc: any) => doc, - }; - const migrationFn = new DocumentMigrator(createDefinition(() => withInvalidVersion)); - const migrationObj = new DocumentMigrator(createDefinition(withInvalidVersion)); + expect(migrationFn.prepareMigrations).toThrow(/Expected all properties to be semvers/i); + expect(migrationObj.prepareMigrations).toThrow(/Expected all properties to be semvers/i); + }); - expect(migrationFn.prepareMigrations).toThrow(/Expected all properties to be semvers/i); - expect(migrationObj.prepareMigrations).toThrow(/Expected all properties to be semvers/i); - }); + it('validates individual migrations are not greater than the current Kibana version', () => { + const withGreaterVersion = { + '3.2.4': (doc: any) => doc, + }; + const migrationFn = new DocumentMigrator(createDefinition(() => withGreaterVersion)); + const migrationObj = new DocumentMigrator(createDefinition(withGreaterVersion)); - it('validates the migration function', () => { - const invalidVersionFunction = { '1.2.3': 23 as any }; - const migrationFn = new DocumentMigrator(createDefinition(() => invalidVersionFunction)); - const migrationObj = new DocumentMigrator(createDefinition(invalidVersionFunction)); + const expectedError = `Invalid migration for type foo. Property '3.2.4' cannot be greater than the current Kibana version '3.2.3'.`; + expect(migrationFn.prepareMigrations).toThrowError(expectedError); + expect(migrationObj.prepareMigrations).toThrowError(expectedError); + }); - expect(migrationFn.prepareMigrations).toThrow(/expected a function, but got 23/i); - expect(migrationObj.prepareMigrations).toThrow(/expected a function, but got 23/i); - }); - it('validates definitions with migrations: Function | Objects', () => { - const validMigrationMap = { '1.2.3': () => {} }; - const migrationFn = new DocumentMigrator(createDefinition(() => validMigrationMap)); - const migrationObj = new DocumentMigrator(createDefinition(validMigrationMap)); - expect(migrationFn.prepareMigrations).not.toThrow(); - expect(migrationObj.prepareMigrations).not.toThrow(); - }); - }); + it('validates the migration function', () => { + const invalidVersionFunction = { '1.2.3': 23 as any }; + const migrationFn = new DocumentMigrator(createDefinition(() => invalidVersionFunction)); + const migrationObj = new DocumentMigrator(createDefinition(invalidVersionFunction)); - it('throws if #prepareMigrations is not called before #migrate is called', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'user', - migrations: { - '1.2.3': setAttr('attributes.name', 'Chris'), - }, - }), + expect(migrationFn.prepareMigrations).toThrow(/expected a function, but got 23/i); + expect(migrationObj.prepareMigrations).toThrow(/expected a function, but got 23/i); + }); + it('validates definitions with migrations: Function | Objects', () => { + const validMigrationMap = { '1.2.3': () => {} }; + const migrationFn = new DocumentMigrator(createDefinition(() => validMigrationMap)); + const migrationObj = new DocumentMigrator(createDefinition(validMigrationMap)); + expect(migrationFn.prepareMigrations).not.toThrow(); + expect(migrationObj.prepareMigrations).not.toThrow(); + }); }); - expect(() => - migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Christopher' }, - migrationVersion: {}, - }) - ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); - }); + it('throws if #prepareMigrations is not called before #migrate or #migrateAndConvert is called', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'user', + migrations: { + '1.2.3': setAttr('attributes.name', 'Chris'), + }, + }), + }); - it('migrates type and attributes', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'user', - migrations: { - '1.2.3': setAttr('attributes.name', 'Chris'), - }, - }), + expect(() => + migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Christopher' }, + migrationVersion: {}, + }) + ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); + + expect(() => + migrator.migrateAndConvert({ + id: 'me', + type: 'user', + attributes: { name: 'Christopher' }, + migrationVersion: {}, + }) + ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Christopher' }, - migrationVersion: {}, + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: 'bar', + }), + minimumConvertVersion: '0.0.0', + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + ); }); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Chris' }, - migrationVersion: { user: '1.2.3' }, + + it(`validates convertToMultiNamespaceTypeVersion must be a semver`, () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: 'bar', + namespaceType: 'multiple', + }), + minimumConvertVersion: '0.0.0', + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected value to be a semver, but got 'bar'.` + ); }); - }); - it(`doesn't mutate the original document`, () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'user', - migrations: { - '1.2.3': (doc) => { - set(doc, 'attributes.name', 'Mike'); - return doc; - }, - }, - }), + it('validates convertToMultiNamespaceTypeVersion is not less than the minimum allowed version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: '3.2.4', + namespaceType: 'multiple', + }), + // not using a minimumConvertVersion parameter, the default is 8.0.0 + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Value '3.2.4' cannot be less than '8.0.0'.` + ); }); - const originalDoc = { - id: 'me', - type: 'user', - attributes: {}, - migrationVersion: {}, - }; - migrator.prepareMigrations(); - const migratedDoc = migrator.migrate(originalDoc); - expect(_.get(originalDoc, 'attributes.name')).toBeUndefined(); - expect(_.get(migratedDoc, 'attributes.name')).toBe('Mike'); - }); - it('migrates root properties', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'acl', - migrations: { - '2.3.5': setAttr('acl', 'admins-only, sucka!'), - }, - }), + it('validates convertToMultiNamespaceTypeVersion is not greater than the current Kibana version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: '3.2.4', + namespaceType: 'multiple', + }), + minimumConvertVersion: '0.0.0', + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Value '3.2.4' cannot be greater than the current Kibana version '3.2.3'.` + ); }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - acl: 'anyone', - migrationVersion: {}, - } as SavedObjectUnsanitizedDoc); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - migrationVersion: { acl: '2.3.5' }, - acl: 'admins-only, sucka!', + + it('validates convertToMultiNamespaceTypeVersion is not used on a patch version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: '3.1.1', + namespaceType: 'multiple', + }), + minimumConvertVersion: '0.0.0', + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Value '3.1.1' cannot be used on a patch version (must be like 'x.y.0').` + ); }); }); - it('does not apply migrations to unrelated docs', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'aaa', - migrations: { - '1.0.0': setAttr('aaa', 'A'), - }, - }, - { - name: 'bbb', - migrations: { - '1.0.0': setAttr('bbb', 'B'), - }, - }, - { - name: 'ccc', + describe('migration', () => { + it('migrates type and attributes', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'user', migrations: { - '1.0.0': setAttr('ccc', 'C'), + '1.2.3': setAttr('attributes.name', 'Chris'), }, - } - ), - }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Christopher' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Chris' }, + migrationVersion: { user: '1.2.3' }, + coreMigrationVersion: kibanaVersion, + }); }); - }); - it('assumes documents w/ undefined migrationVersion are up to date', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { + it(`doesn't mutate the original document`, () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ name: 'user', migrations: { - '1.0.0': setAttr('aaa', 'A'), - }, - }, - { - name: 'bbb', - migrations: { - '2.3.4': setAttr('bbb', 'B'), + '1.2.3': (doc) => { + set(doc, 'attributes.name', 'Mike'); + return doc; + }, }, - }, - { - name: 'ccc', + }), + }); + migrator.prepareMigrations(); + const originalDoc = { + id: 'me', + type: 'user', + attributes: {}, + migrationVersion: {}, + }; + const migratedDoc = migrator.migrate(originalDoc); + expect(_.get(originalDoc, 'attributes.name')).toBeUndefined(); + expect(_.get(migratedDoc, 'attributes.name')).toBe('Mike'); + }); + + it('migrates root properties', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'acl', migrations: { - '1.0.0': setAttr('ccc', 'C'), + '2.3.5': setAttr('acl', 'admins-only, sucka!'), }, - } - ), + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + acl: 'anyone', + migrationVersion: {}, + } as SavedObjectUnsanitizedDoc); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + migrationVersion: { acl: '2.3.5' }, + acl: 'admins-only, sucka!', + coreMigrationVersion: kibanaVersion, + }); }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - bbb: 'Shazm', - } as SavedObjectUnsanitizedDoc); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - bbb: 'Shazm', - migrationVersion: { - user: '1.0.0', - bbb: '2.3.4', - }, + + it('does not apply migrations to unrelated docs', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'aaa', + migrations: { + '1.0.0': setAttr('aaa', 'A'), + }, + }, + { + name: 'bbb', + migrations: { + '1.0.0': setAttr('bbb', 'B'), + }, + }, + { + name: 'ccc', + migrations: { + '1.0.0': setAttr('ccc', 'C'), + }, + } + ), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + coreMigrationVersion: kibanaVersion, + }); }); - }); - it('only applies migrations that are more recent than the doc', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '1.2.3': setAttr('attributes.a', 'A'), - '1.2.4': setAttr('attributes.b', 'B'), - '2.0.1': setAttr('attributes.c', 'C'), + it('assumes documents w/ undefined migrationVersion and correct coreMigrationVersion are up to date', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'user', + migrations: { + '1.0.0': setAttr('aaa', 'A'), + }, + }, + { + name: 'bbb', + migrations: { + '2.3.4': setAttr('bbb', 'B'), + }, + }, + { + name: 'ccc', + migrations: { + '1.0.0': setAttr('ccc', 'C'), + }, + } + ), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + bbb: 'Shazm', + coreMigrationVersion: kibanaVersion, + } as SavedObjectUnsanitizedDoc); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + bbb: 'Shazm', + migrationVersion: { + user: '1.0.0', + bbb: '2.3.4', }, - }), - }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: { dog: '1.2.3' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie', b: 'B', c: 'C' }, - migrationVersion: { dog: '2.0.1' }, + coreMigrationVersion: kibanaVersion, + }); }); - }); - it('rejects docs that belong to a newer Kibana instance', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - kibanaVersion: '8.0.1', - }); - migrator.prepareMigrations(); - expect(() => - migrator.migrate({ + it('only applies migrations that are more recent than the doc', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.2.3': setAttr('attributes.a', 'A'), + '1.2.4': setAttr('attributes.b', 'B'), + '2.0.1': setAttr('attributes.c', 'C'), + }, + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ id: 'smelly', type: 'dog', attributes: { name: 'Callie' }, - migrationVersion: { dog: '10.2.0' }, - }) - ).toThrow( - /Document "smelly" has property "dog" which belongs to a more recent version of Kibana \[10\.2\.0\]\. The last known version is \[undefined\]/i - ); - }); + migrationVersion: { dog: '1.2.3' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie', b: 'B', c: 'C' }, + migrationVersion: { dog: '2.0.1' }, + coreMigrationVersion: kibanaVersion, + }); + }); - it('rejects docs that belong to a newer plugin', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dawg', - migrations: { - '1.2.3': setAttr('attributes.a', 'A'), - }, - }), + it('rejects docs with a migrationVersion[type] for a type that does not have any migrations defined', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + }); + migrator.prepareMigrations(); + expect(() => + migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: { dog: '10.2.0' }, + }) + ).toThrow( + /Document "smelly" has property "dog" which belongs to a more recent version of Kibana \[10\.2\.0\]\. The last known version is \[undefined\]/i + ); }); - migrator.prepareMigrations(); - expect(() => - migrator.migrate({ - id: 'fleabag', - type: 'dawg', - attributes: { name: 'Callie' }, - migrationVersion: { dawg: '1.2.4' }, - }) - ).toThrow( - /Document "fleabag" has property "dawg" which belongs to a more recent version of Kibana \[1\.2\.4\]\. The last known version is \[1\.2\.3\]/i - ); - }); - it('applies migrations in order', () => { - let count = 0; - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '2.2.4': setAttr('attributes.b', () => ++count), - '10.0.1': setAttr('attributes.c', () => ++count), - '1.2.3': setAttr('attributes.a', () => ++count), - }, - }), + it('rejects docs with a migrationVersion[type] for a type that does not have a migration >= that version defined', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dawg', + migrations: { + '1.2.3': setAttr('attributes.a', 'A'), + }, + }), + }); + migrator.prepareMigrations(); + expect(() => + migrator.migrate({ + id: 'fleabag', + type: 'dawg', + attributes: { name: 'Callie' }, + migrationVersion: { dawg: '1.2.4' }, + }) + ).toThrow( + /Document "fleabag" has property "dawg" which belongs to a more recent version of Kibana \[1\.2\.4\]\. The last known version is \[1\.2\.3\]/i + ); }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: { dog: '1.2.0' }, + + it('rejects docs that have an invalid coreMigrationVersion', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + kibanaVersion: '8.0.1', + }); + migrator.prepareMigrations(); + expect(() => + migrator.migrate({ + id: 'happy', + type: 'dog', + attributes: { name: 'Callie' }, + coreMigrationVersion: 'not-a-semver', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Document \\"happy\\" has an invalid \\"coreMigrationVersion\\" [not-a-semver]. This must be a semver value."` + ); }); - expect(actual).toEqual({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie', a: 1, b: 2, c: 3 }, - migrationVersion: { dog: '10.0.1' }, + + it('rejects docs that have a coreMigrationVersion higher than the current Kibana version', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + kibanaVersion: '8.0.1', + }); + migrator.prepareMigrations(); + expect(() => + migrator.migrate({ + id: 'wet', + type: 'dog', + attributes: { name: 'Callie' }, + coreMigrationVersion: '8.0.2', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Document \\"wet\\" has a \\"coreMigrationVersion\\" which belongs to a more recent version of Kibana [8.0.2]. The current version is [8.0.1]."` + ); }); - }); - it('allows props to be added', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'animal', - migrations: { - '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), - }, - }, - { + it('applies migrations in order', () => { + let count = 0; + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ name: 'dog', migrations: { - '2.2.4': setAttr('animal', 'Doggie'), + '2.2.4': setAttr('attributes.b', () => ++count), + '10.0.1': setAttr('attributes.c', () => ++count), + '1.2.3': setAttr('attributes.a', () => ++count), }, - } - ), - }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: { dog: '1.2.0' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - animal: 'Animal: Doggie', - migrationVersion: { animal: '1.0.0', dog: '2.2.4' }, + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: { dog: '1.2.0' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie', a: 1, b: 2, c: 3 }, + migrationVersion: { dog: '10.0.1' }, + coreMigrationVersion: kibanaVersion, + }); }); - }); - it('allows props to be renamed', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'animal', - migrations: { - '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), - '3.2.1': renameAttr('animal', 'dawg'), + it('allows props to be added', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'animal', + migrations: { + '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), + }, }, - }, - { - name: 'dawg', + { + name: 'dog', + migrations: { + '2.2.4': setAttr('animal', 'Doggie'), + }, + } + ), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: { dog: '1.2.0' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + animal: 'Animal: Doggie', + migrationVersion: { animal: '1.0.0', dog: '2.2.4' }, + coreMigrationVersion: kibanaVersion, + }); + }); + + it('allows props to be renamed', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', migrations: { - '2.2.4': renameAttr('dawg', 'animal'), - '3.2.0': setAttr('dawg', (name: string) => `Dawg3.x: ${name}`), + '1.0.0': setAttr('attributes.name', (name: string) => `Name: ${name}`), + '1.0.1': renameAttr('attributes.name', 'attributes.title'), + '1.0.2': setAttr('attributes.title', (name: string) => `Title: ${name}`), }, - } - ), + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { title: 'Title: Name: Callie' }, + migrationVersion: { dog: '1.0.2' }, + coreMigrationVersion: kibanaVersion, + }); }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'foo', - attributes: { name: 'Callie' }, - dawg: 'Yo', - migrationVersion: {}, - } as SavedObjectUnsanitizedDoc); - expect(actual).toEqual({ - id: 'smelly', - type: 'foo', - attributes: { name: 'Callie' }, - dawg: 'Dawg3.x: Animal: Yo', - migrationVersion: { animal: '3.2.1', dawg: '3.2.0' }, + + it('allows changing type', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'cat', + migrations: { + '1.0.0': setAttr('attributes.name', (name: string) => `Kitty ${name}`), + }, + }, + { + name: 'dog', + migrations: { + '2.2.4': setAttr('type', 'cat'), + }, + } + ), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Kitty Callie' }, + migrationVersion: { dog: '2.2.4', cat: '1.0.0' }, + coreMigrationVersion: kibanaVersion, + }); }); - }); - it('allows changing type', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { + it('disallows updating a migrationVersion prop to a lower version', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ name: 'cat', migrations: { - '1.0.0': setAttr('attributes.name', (name: string) => `Kitty ${name}`), + '1.0.0': setAttr('migrationVersion.foo', '3.2.1'), }, - }, - { - name: 'dog', + }), + }); + migrator.prepareMigrations(); + expect(() => + migrator.migrate({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Boo' }, + migrationVersion: { foo: '4.5.6' }, + }) + ).toThrow( + /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to 3.2.1./ + ); + }); + + it('disallows removing a migrationVersion prop', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'cat', migrations: { - '2.2.4': setAttr('type', 'cat'), + '1.0.0': setAttr('migrationVersion', {}), }, - } - ), - }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Kitty Callie' }, - migrationVersion: { dog: '2.2.4', cat: '1.0.0' }, + }), + }); + migrator.prepareMigrations(); + expect(() => + migrator.migrate({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Boo' }, + migrationVersion: { foo: '4.5.6' }, + }) + ).toThrow( + /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to undefined./ + ); }); - }); - it('disallows updating a migrationVersion prop to a lower version', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion.foo', '3.2.1'), - }, - }), + it('allows updating a migrationVersion prop to a later version', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'cat', + migrations: { + '1.0.0': setAttr('migrationVersion.cat', '2.9.1'), + '2.0.0': () => { + throw new Error('POW!'); + }, + '2.9.1': () => { + throw new Error('BANG!'); + }, + '3.0.0': setAttr('attributes.name', 'Shiny'), + }, + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Boo' }, + migrationVersion: { cat: '0.5.6' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Shiny' }, + migrationVersion: { cat: '3.0.0' }, + coreMigrationVersion: kibanaVersion, + }); }); - migrator.prepareMigrations(); - expect(() => - migrator.migrate({ + it('allows adding props to migrationVersion', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'cat', + migrations: { + '1.0.0': setAttr('migrationVersion.foo', '5.6.7'), + }, + }), + }); + migrator.prepareMigrations(); + const actual = migrator.migrate({ id: 'smelly', type: 'cat', attributes: { name: 'Boo' }, - migrationVersion: { foo: '4.5.6' }, - }) - ).toThrow( - /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to 3.2.1./ - ); - }); - - it('disallows removing a migrationVersion prop', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion', {}), - }, - }), - }); - migrator.prepareMigrations(); - expect(() => - migrator.migrate({ + migrationVersion: {}, + }); + expect(actual).toEqual({ id: 'smelly', type: 'cat', attributes: { name: 'Boo' }, - migrationVersion: { foo: '4.5.6' }, - }) - ).toThrow( - /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to undefined./ - ); - }); - - it('allows updating a migrationVersion prop to a later version', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion.cat', '2.9.1'), - '2.0.0': () => { - throw new Error('POW!'); - }, - '2.9.1': () => { - throw new Error('BANG!'); - }, - '3.0.0': setAttr('attributes.name', 'Shiny'), - }, - }), - }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Boo' }, - migrationVersion: { cat: '0.5.6' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Shiny' }, - migrationVersion: { cat: '3.0.0' }, + migrationVersion: { cat: '1.0.0', foo: '5.6.7' }, + coreMigrationVersion: kibanaVersion, + }); }); - }); - it('allows adding props to migrationVersion', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion.foo', '5.6.7'), - }, - }), - }); - migrator.prepareMigrations(); - const actual = migrator.migrate({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Boo' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Boo' }, - migrationVersion: { cat: '1.0.0', foo: '5.6.7' }, - }); - }); - - it('logs the document and transform that failed', () => { - const log = mockLogger; - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '1.2.3': () => { - throw new Error('Dang diggity!'); + it('logs the document and transform that failed', () => { + const log = mockLogger; + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.2.3': () => { + throw new Error('Dang diggity!'); + }, }, - }, - }), - log, - }); - const failedDoc = { - id: 'smelly', - type: 'dog', - attributes: {}, - migrationVersion: {}, - }; - try { + }), + log, + }); migrator.prepareMigrations(); - migrator.migrate(_.cloneDeep(failedDoc)); - expect('Did not throw').toEqual('But it should have!'); - } catch (error) { - expect(error.message).toMatch(/Dang diggity!/); - const warning = loggingSystemMock.collect(mockLoggerFactory).warn[0][0]; - expect(warning).toContain(JSON.stringify(failedDoc)); - expect(warning).toContain('dog:1.2.3'); - } - }); + const failedDoc = { + id: 'smelly', + type: 'dog', + attributes: {}, + migrationVersion: {}, + }; + try { + migrator.migrate(_.cloneDeep(failedDoc)); + expect('Did not throw').toEqual('But it should have!'); + } catch (error) { + expect(error.message).toMatch(/Dang diggity!/); + const warning = loggingSystemMock.collect(mockLoggerFactory).warn[0][0]; + expect(warning).toContain(JSON.stringify(failedDoc)); + expect(warning).toContain('dog:1.2.3'); + } + }); - it('logs message in transform function', () => { - const logTestMsg = '...said the joker to the thief'; - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '1.2.3': (doc, { log }) => { - log.info(logTestMsg); - log.warning(logTestMsg); - return doc; + it('logs message in transform function', () => { + const logTestMsg = '...said the joker to the thief'; + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.2.3': (doc, { log }) => { + log.info(logTestMsg); + log.warning(logTestMsg); + return doc; + }, }, - }, - }), - log: mockLogger, + }), + log: mockLogger, + }); + migrator.prepareMigrations(); + const doc = { + id: 'joker', + type: 'dog', + attributes: {}, + migrationVersion: {}, + }; + migrator.migrate(doc); + expect(loggingSystemMock.collect(mockLoggerFactory).info[0][0]).toEqual(logTestMsg); + expect(loggingSystemMock.collect(mockLoggerFactory).warn[1][0]).toEqual(logTestMsg); }); - const doc = { - id: 'joker', - type: 'dog', - attributes: {}, - migrationVersion: {}, - }; - migrator.prepareMigrations(); - migrator.migrate(doc); - expect(loggingSystemMock.collect(mockLoggerFactory).info[0][0]).toEqual(logTestMsg); - expect(loggingSystemMock.collect(mockLoggerFactory).warn[1][0]).toEqual(logTestMsg); - }); - test('extracts the latest migration version info', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'aaa', - migrations: { - '1.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, - '10.4.0': (doc: SavedObjectUnsanitizedDoc) => doc, - '2.2.1': (doc: SavedObjectUnsanitizedDoc) => doc, + test('extracts the latest migration version info', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'aaa', + migrations: { + '1.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, + '10.4.0': (doc: SavedObjectUnsanitizedDoc) => doc, + '2.2.1': (doc: SavedObjectUnsanitizedDoc) => doc, + }, }, - }, - { - name: 'bbb', - migrations: { - '3.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, - '2.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, + { + name: 'bbb', + migrations: { + '3.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, + '2.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, + }, }, - } - ), + { + name: 'ccc', + namespaceType: 'multiple', + migrations: { + '9.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, + }, + convertToMultiNamespaceTypeVersion: '11.0.0', // this results in reference transforms getting added to other types, but does not increase the migrationVersion of those types + } + ), + }); + migrator.prepareMigrations(); + expect(migrator.migrationVersion).toEqual({ + aaa: '10.4.0', + bbb: '3.2.3', + ccc: '11.0.0', + }); }); - migrator.prepareMigrations(); - expect(migrator.migrationVersion).toEqual({ - aaa: '10.4.0', - bbb: '3.2.3', + describe('conversion to multi-namespace type', () => { + it('assumes documents w/ undefined migrationVersion and correct coreMigrationVersion are up to date', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + // no migration transforms are defined, the migrationVersion will be derived from 'convertToMultiNamespaceTypeVersion' + ), + }); + migrator.prepareMigrations(); + const obj = { + id: 'mischievous', + type: 'dog', + attributes: { name: 'Ann' }, + coreMigrationVersion: kibanaVersion, + } as SavedObjectUnsanitizedDoc; + const actual = migrator.migrateAndConvert(obj); + expect(actual).toEqual([ + { + id: 'mischievous', + type: 'dog', + attributes: { name: 'Ann' }, + migrationVersion: { dog: '1.0.0' }, + coreMigrationVersion: kibanaVersion, + // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + }, + ]); + }); + + it('skips reference transforms and conversion transforms when using `migrate`', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + migrator.prepareMigrations(); + const obj = { + id: 'cowardly', + type: 'dog', + attributes: { name: 'Leslie' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + namespace: 'foo-namespace', + }; + const actual = migrator.migrate(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'cowardly', + type: 'dog', + attributes: { name: 'Leslie' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + coreMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + // there is no 'namespaces' field because no conversion transform was applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + }); + }); + + describe('correctly applies reference transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'single' }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + migrator.prepareMigrations(); + const obj = { + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual([ + { + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + coreMigrationVersion: kibanaVersion, + }, + ]); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:toy:favorite', 'DNSUUID'); + expect(actual).toEqual([ + { + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + coreMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + }, + ]); + }); + }); + + describe('correctly applies conversion transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '1.0.0', + }), + }); + migrator.prepareMigrations(); + const obj = { + id: 'loud', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: {}, + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual([ + { + id: 'loud', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: { dog: '1.0.0' }, + coreMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:dog:loud', 'DNSUUID'); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: { dog: '1.0.0' }, + coreMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'loud', + }, + { + id: 'foo-namespace:dog:loud', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + coreMigrationVersion: kibanaVersion, + }, + ]); + }); + }); + + describe('correctly applies reference and conversion transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + migrator.prepareMigrations(); + const obj = { + id: 'cute', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual([ + { + id: 'cute', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + coreMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(2); + expect(mockUuidv5).toHaveBeenNthCalledWith(1, 'foo-namespace:toy:favorite', 'DNSUUID'); + expect(mockUuidv5).toHaveBeenNthCalledWith(2, 'foo-namespace:dog:cute', 'DNSUUID'); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + coreMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'cute', + }, + { + id: 'foo-namespace:dog:cute', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + coreMigrationVersion: kibanaVersion, + }, + ]); + }); + }); + + describe('correctly applies reference and migration transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'dog', + namespaceType: 'single', + migrations: { + '1.0.0': setAttr('migrationVersion.dog', '2.0.0'), + '2.0.0': (doc) => doc, // noop + }, + }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + migrator.prepareMigrations(); + const obj = { + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual([ + { + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + coreMigrationVersion: kibanaVersion, + }, + ]); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:toy:favorite', 'DNSUUID'); + expect(actual).toEqual([ + { + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + coreMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + }, + ]); + }); + }); + + describe('correctly applies conversion and migration transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + namespaceType: 'multiple', + migrations: { + '1.0.0': setAttr('migrationVersion.dog', '2.0.0'), + '2.0.0': (doc) => doc, // noop + }, + convertToMultiNamespaceTypeVersion: '1.0.0', // the conversion transform occurs before the migration transform above + }), + }); + migrator.prepareMigrations(); + const obj = { + id: 'hungry', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: {}, + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual([ + { + id: 'hungry', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: { dog: '2.0.0' }, + coreMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:dog:hungry', 'DNSUUID'); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: { dog: '2.0.0' }, + coreMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'hungry', + }, + { + id: 'foo-namespace:dog:hungry', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + coreMigrationVersion: kibanaVersion, + }, + ]); + }); + }); + + describe('correctly applies reference, conversion, and migration transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'dog', + namespaceType: 'multiple', + migrations: { + '1.0.0': setAttr('migrationVersion.dog', '2.0.0'), + '2.0.0': (doc) => doc, // noop + }, + convertToMultiNamespaceTypeVersion: '1.0.0', + }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + migrator.prepareMigrations(); + const obj = { + id: 'pretty', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual([ + { + id: 'pretty', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + coreMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(2); + expect(mockUuidv5).toHaveBeenNthCalledWith(1, 'foo-namespace:toy:favorite', 'DNSUUID'); + expect(mockUuidv5).toHaveBeenNthCalledWith(2, 'foo-namespace:dog:pretty', 'DNSUUID'); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + coreMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'pretty', + }, + { + id: 'foo-namespace:dog:pretty', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + coreMigrationVersion: kibanaVersion, + }, + ]); + }); + }); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 04e9a4e165f96..e4b89a949d3cf 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -50,50 +50,102 @@ */ import Boom from '@hapi/boom'; +import uuidv5 from 'uuid/v5'; import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; import Semver from 'semver'; import { Logger } from '../../../logging'; import { SavedObjectUnsanitizedDoc } from '../../serialization'; -import { SavedObjectsMigrationVersion } from '../../types'; +import { + SavedObjectsMigrationVersion, + SavedObjectsNamespaceType, + SavedObjectsType, +} from '../../types'; import { MigrationLogger } from './migration_logger'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectMigrationFn, SavedObjectMigrationMap } from '../types'; +import { DEFAULT_NAMESPACE_STRING } from '../../service/lib/utils'; +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; -export type TransformFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; +const DEFAULT_MINIMUM_CONVERT_VERSION = '8.0.0'; + +export type MigrateFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; +export type MigrateAndConvertFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc[]; + +interface TransformResult { + /** + * This is the original document that has been transformed. + */ + transformedDoc: SavedObjectUnsanitizedDoc; + /** + * These are any new document(s) that have been created during the transformation process; these are not transformed, but they are marked + * as up-to-date. Only conversion transforms generate additional documents. + */ + additionalDocs: SavedObjectUnsanitizedDoc[]; +} + +type ApplyTransformsFn = ( + doc: SavedObjectUnsanitizedDoc, + options?: TransformOptions +) => TransformResult; + +interface TransformOptions { + convertNamespaceTypes?: boolean; +} interface DocumentMigratorOptions { kibanaVersion: string; typeRegistry: ISavedObjectTypeRegistry; + minimumConvertVersion?: string; log: Logger; } interface ActiveMigrations { [type: string]: { - latestVersion: string; - transforms: Array<{ - version: string; - transform: TransformFn; - }>; + /** Derived from `migrate` transforms and `convert` transforms */ + latestMigrationVersion?: string; + /** Derived from `reference` transforms */ + latestCoreMigrationVersion?: string; + transforms: Transform[]; }; } +interface Transform { + version: string; + transform: (doc: SavedObjectUnsanitizedDoc) => TransformResult; + /** + * There are two "migrationVersion" transform types: + * * `migrate` - These transforms are defined and added by consumers using the type registry; each is applied to a single object type + * based on an object's `migrationVersion[type]` field. These are applied during index migrations and document migrations. + * * `convert` - These transforms are defined by core and added by consumers using the type registry; each is applied to a single object + * type based on an object's `migrationVersion[type]` field. These are applied during index migrations, NOT document migrations. + * + * There is one "coreMigrationVersion" transform type: + * * `reference` - These transforms are defined by core and added by consumers using the type registry; they are applied to all object + * types based on their `coreMigrationVersion` field. These are applied during index migrations, NOT document migrations. + * + * If any additional transform types are added, the functions below should be updated to account for them. + */ + transformType: 'migrate' | 'convert' | 'reference'; +} + /** * Manages migration of individual documents. */ export interface VersionedTransformer { migrationVersion: SavedObjectsMigrationVersion; + migrate: MigrateFn; + migrateAndConvert: MigrateAndConvertFn; prepareMigrations: () => void; - migrate: TransformFn; } /** * A concrete implementation of the VersionedTransformer interface. */ export class DocumentMigrator implements VersionedTransformer { - private documentMigratorOptions: DocumentMigratorOptions; + private documentMigratorOptions: Omit; private migrations?: ActiveMigrations; - private transformDoc?: TransformFn; + private transformDoc?: ApplyTransformsFn; /** * Creates an instance of DocumentMigrator. @@ -101,11 +153,18 @@ export class DocumentMigrator implements VersionedTransformer { * @param {DocumentMigratorOptions} opts * @prop {string} kibanaVersion - The current version of Kibana * @prop {SavedObjectTypeRegistry} typeRegistry - The type registry to get type migrations from + * @prop {string} minimumConvertVersion - The minimum version of Kibana in which documents can be converted to multi-namespace types * @prop {Logger} log - The migration logger * @memberof DocumentMigrator */ - constructor({ typeRegistry, kibanaVersion, log }: DocumentMigratorOptions) { - validateMigrationDefinition(typeRegistry); + constructor({ + typeRegistry, + kibanaVersion, + minimumConvertVersion = DEFAULT_MINIMUM_CONVERT_VERSION, + log, + }: DocumentMigratorOptions) { + validateMigrationDefinition(typeRegistry, kibanaVersion, minimumConvertVersion); + this.documentMigratorOptions = { typeRegistry, kibanaVersion, log }; } @@ -120,7 +179,14 @@ export class DocumentMigrator implements VersionedTransformer { if (!this.migrations) { throw new Error('Migrations are not ready. Make sure prepareMigrations is called first.'); } - return _.mapValues(this.migrations, ({ latestVersion }) => latestVersion); + + return Object.entries(this.migrations).reduce((acc, [prop, { latestMigrationVersion }]) => { + // some migration objects won't have a latestMigrationVersion (they only contain reference transforms that are applied from other types) + if (latestMigrationVersion) { + return { ...acc, [prop]: latestMigrationVersion }; + } + return acc; + }, {}); } /** @@ -132,7 +198,7 @@ export class DocumentMigrator implements VersionedTransformer { public prepareMigrations = () => { const { typeRegistry, kibanaVersion, log } = this.documentMigratorOptions; - this.migrations = buildActiveMigrations(typeRegistry, log); + this.migrations = buildActiveMigrations(typeRegistry, kibanaVersion, log); this.transformDoc = buildDocumentTransform({ kibanaVersion, migrations: this.migrations, @@ -155,25 +221,56 @@ export class DocumentMigrator implements VersionedTransformer { // Ex: Importing sample data that is cached at import level, migrations would // execute on mutated data the second time. const clonedDoc = _.cloneDeep(doc); - return this.transformDoc(clonedDoc); + const { transformedDoc } = this.transformDoc(clonedDoc); + return transformedDoc; + }; + + /** + * Migrates a document to the latest version and applies type conversions if applicable. Also returns any additional document(s) that may + * have been created during the transformation process. + * + * @param {SavedObjectUnsanitizedDoc} doc + * @returns {SavedObjectUnsanitizedDoc} + * @memberof DocumentMigrator + */ + public migrateAndConvert = (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc[] => { + if (!this.migrations || !this.transformDoc) { + throw new Error('Migrations are not ready. Make sure prepareMigrations is called first.'); + } + + // Clone the document to prevent accidental mutations on the original data + // Ex: Importing sample data that is cached at import level, migrations would + // execute on mutated data the second time. + const clonedDoc = _.cloneDeep(doc); + const { transformedDoc, additionalDocs } = this.transformDoc(clonedDoc, { + convertNamespaceTypes: true, + }); + return [transformedDoc, ...additionalDocs]; }; } -function validateMigrationsMapObject(name: string, migrationsMap?: SavedObjectMigrationMap) { +function validateMigrationsMapObject( + name: string, + kibanaVersion: string, + migrationsMap?: SavedObjectMigrationMap +) { function assertObject(obj: any, prefix: string) { if (!obj || typeof obj !== 'object') { throw new Error(`${prefix} Got ${obj}.`); } } - function assertValidSemver(version: string, type: string) { if (!Semver.valid(version)) { throw new Error( `Invalid migration for type ${type}. Expected all properties to be semvers, but got ${version}.` ); } + if (Semver.gt(version, kibanaVersion)) { + throw new Error( + `Invalid migration for type ${type}. Property '${version}' cannot be greater than the current Kibana version '${kibanaVersion}'.` + ); + } } - function assertValidTransform(fn: any, version: string, type: string) { if (typeof fn !== 'function') { throw new Error(`Invalid migration ${type}.${version}: expected a function, but got ${fn}.`); @@ -194,23 +291,63 @@ function validateMigrationsMapObject(name: string, migrationsMap?: SavedObjectMi } /** - * Basic validation that the migraiton definition matches our expectations. We can't + * Basic validation that the migration definition matches our expectations. We can't * rely on TypeScript here, as the caller may be JavaScript / ClojureScript / any compile-to-js * language. So, this is just to provide a little developer-friendly error messaging. Joi was * giving weird errors, so we're just doing manual validation. */ -function validateMigrationDefinition(registry: ISavedObjectTypeRegistry) { +function validateMigrationDefinition( + registry: ISavedObjectTypeRegistry, + kibanaVersion: string, + minimumConvertVersion: string +) { function assertObjectOrFunction(entity: any, prefix: string) { if (!entity || (typeof entity !== 'function' && typeof entity !== 'object')) { throw new Error(`${prefix} Got! ${typeof entity}, ${JSON.stringify(entity)}.`); } } + function assertValidConvertToMultiNamespaceType( + namespaceType: SavedObjectsNamespaceType, + convertToMultiNamespaceTypeVersion: string, + type: string + ) { + if (namespaceType !== 'multiple') { + throw new Error( + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.` + ); + } else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) { + throw new Error( + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected value to be a semver, but got '${convertToMultiNamespaceTypeVersion}'.` + ); + } else if (Semver.lt(convertToMultiNamespaceTypeVersion, minimumConvertVersion)) { + throw new Error( + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Value '${convertToMultiNamespaceTypeVersion}' cannot be less than '${minimumConvertVersion}'.` + ); + } else if (Semver.gt(convertToMultiNamespaceTypeVersion, kibanaVersion)) { + throw new Error( + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Value '${convertToMultiNamespaceTypeVersion}' cannot be greater than the current Kibana version '${kibanaVersion}'.` + ); + } else if (Semver.patch(convertToMultiNamespaceTypeVersion)) { + throw new Error( + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Value '${convertToMultiNamespaceTypeVersion}' cannot be used on a patch version (must be like 'x.y.0').` + ); + } + } + registry.getAllTypes().forEach((type) => { - if (type.migrations) { + const { name, migrations, convertToMultiNamespaceTypeVersion, namespaceType } = type; + if (migrations) { assertObjectOrFunction( type.migrations, - `Migration for type ${type.name} should be an object or a function returning an object like { '2.0.0': (doc) => doc }.` + `Migration for type ${name} should be an object or a function returning an object like { '2.0.0': (doc) => doc }.` + ); + } + if (convertToMultiNamespaceTypeVersion) { + assertValidConvertToMultiNamespaceType( + namespaceType, + convertToMultiNamespaceTypeVersion, + name ); } }); @@ -220,74 +357,144 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry) { * Converts migrations from a format that is convenient for callers to a format that * is convenient for our internal usage: * From: { type: { version: fn } } - * To: { type: { latestVersion: string, transforms: [{ version: string, transform: fn }] } } + * To: { type: { latestMigrationVersion?: string; latestCoreMigrationVersion?: string; transforms: [{ version: string, transform: fn }] } } */ function buildActiveMigrations( typeRegistry: ISavedObjectTypeRegistry, + kibanaVersion: string, log: Logger ): ActiveMigrations { - const typesWithMigrationMaps = typeRegistry - .getAllTypes() - .map((type) => ({ - ...type, - migrationsMap: typeof type.migrations === 'function' ? type.migrations() : type.migrations, - })) - .filter((type) => typeof type.migrationsMap !== 'undefined'); - - typesWithMigrationMaps.forEach((type) => - validateMigrationsMapObject(type.name, type.migrationsMap) - ); + const referenceTransforms = getReferenceTransforms(typeRegistry); + + return typeRegistry.getAllTypes().reduce((migrations, type) => { + const migrationsMap = + typeof type.migrations === 'function' ? type.migrations() : type.migrations; + validateMigrationsMapObject(type.name, kibanaVersion, migrationsMap); + + const migrationTransforms = Object.entries(migrationsMap ?? {}).map( + ([version, transform]) => ({ + version, + transform: wrapWithTry(version, type.name, transform, log), + transformType: 'migrate', + }) + ); + const conversionTransforms = getConversionTransforms(type); + const transforms = [ + ...referenceTransforms, + ...conversionTransforms, + ...migrationTransforms, + ].sort(transformComparator); + + if (!transforms.length) { + return migrations; + } - return typesWithMigrationMaps - .filter((type) => type.migrationsMap && Object.keys(type.migrationsMap).length > 0) - .reduce((migrations, type) => { - const transforms = Object.entries(type.migrationsMap!) - .map(([version, transform]) => ({ - version, - transform: wrapWithTry(version, type.name, transform, log), - })) - .sort((a, b) => Semver.compare(a.version, b.version)); - return { - ...migrations, - [type.name]: { - latestVersion: _.last(transforms)!.version, - transforms, - }, - }; - }, {} as ActiveMigrations); + const migrationVersionTransforms: Transform[] = []; + const coreMigrationVersionTransforms: Transform[] = []; + transforms.forEach((x) => { + if (x.transformType === 'migrate' || x.transformType === 'convert') { + migrationVersionTransforms.push(x); + } else { + coreMigrationVersionTransforms.push(x); + } + }); + + return { + ...migrations, + [type.name]: { + latestMigrationVersion: _.last(migrationVersionTransforms)?.version, + latestCoreMigrationVersion: _.last(coreMigrationVersionTransforms)?.version, + transforms, + }, + }; + }, {} as ActiveMigrations); } + /** * Creates a function which migrates and validates any document that is passed to it. */ function buildDocumentTransform({ + kibanaVersion, migrations, }: { kibanaVersion: string; migrations: ActiveMigrations; -}): TransformFn { - return function transformAndValidate(doc: SavedObjectUnsanitizedDoc) { - const result = doc.migrationVersion - ? applyMigrations(doc, migrations) - : markAsUpToDate(doc, migrations); +}): ApplyTransformsFn { + return function transformAndValidate( + doc: SavedObjectUnsanitizedDoc, + options: TransformOptions = {} + ) { + validateCoreMigrationVersion(doc, kibanaVersion); + + const { convertNamespaceTypes = false } = options; + let transformedDoc: SavedObjectUnsanitizedDoc; + let additionalDocs: SavedObjectUnsanitizedDoc[] = []; + if (doc.migrationVersion) { + const result = applyMigrations(doc, migrations, kibanaVersion, convertNamespaceTypes); + transformedDoc = result.transformedDoc; + additionalDocs = additionalDocs.concat( + result.additionalDocs.map((x) => markAsUpToDate(x, migrations, kibanaVersion)) + ); + } else { + transformedDoc = markAsUpToDate(doc, migrations, kibanaVersion); + } // In order to keep tests a bit more stable, we won't // tack on an empy migrationVersion to docs that have // no migrations defined. - if (_.isEmpty(result.migrationVersion)) { - delete result.migrationVersion; + if (_.isEmpty(transformedDoc.migrationVersion)) { + delete transformedDoc.migrationVersion; } - return result; + return { transformedDoc, additionalDocs }; }; } -function applyMigrations(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) { +function validateCoreMigrationVersion(doc: SavedObjectUnsanitizedDoc, kibanaVersion: string) { + const { id, coreMigrationVersion: docVersion } = doc; + if (!docVersion) { + return; + } + + // We verify that the object's coreMigrationVersion is valid, and that it is not greater than the version supported by Kibana. + // If we have a coreMigrationVersion and the kibanaVersion is smaller than it or does not exist, we are dealing with a document that + // belongs to a future Kibana / plugin version. + if (!Semver.valid(docVersion)) { + throw Boom.badData( + `Document "${id}" has an invalid "coreMigrationVersion" [${docVersion}]. This must be a semver value.`, + doc + ); + } + + if (doc.coreMigrationVersion && Semver.gt(docVersion, kibanaVersion)) { + throw Boom.badData( + `Document "${id}" has a "coreMigrationVersion" which belongs to a more recent version` + + ` of Kibana [${docVersion}]. The current version is [${kibanaVersion}].`, + doc + ); + } +} + +function applyMigrations( + doc: SavedObjectUnsanitizedDoc, + migrations: ActiveMigrations, + kibanaVersion: string, + convertNamespaceTypes: boolean +) { + let additionalDocs: SavedObjectUnsanitizedDoc[] = []; while (true) { const prop = nextUnmigratedProp(doc, migrations); if (!prop) { - return doc; + // regardless of whether or not any reference transform was applied, update the coreMigrationVersion + // this is needed to ensure that newly created documents have an up-to-date coreMigrationVersion field + return { + transformedDoc: { ...doc, coreMigrationVersion: kibanaVersion }, + additionalDocs, + }; } - doc = migrateProp(doc, prop, migrations); + const result = migrateProp(doc, prop, migrations, convertNamespaceTypes); + doc = result.transformedDoc; + additionalDocs = [...additionalDocs, ...result.additionalDocs]; } } @@ -303,7 +510,7 @@ function props(doc: SavedObjectUnsanitizedDoc) { */ function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: string) { return ( - ((doc as any)[prop] && (doc as any)[prop].latestVersion) || + ((doc as any)[prop] && (doc as any)[prop].latestMigrationVersion) || (doc.migrationVersion && (doc as any).migrationVersion[prop]) ); } @@ -311,16 +518,137 @@ function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: st /** * Sets the doc's migrationVersion to be the most recent version */ -function markAsUpToDate(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) { +function markAsUpToDate( + doc: SavedObjectUnsanitizedDoc, + migrations: ActiveMigrations, + kibanaVersion: string +) { return { ...doc, migrationVersion: props(doc).reduce((acc, prop) => { const version = propVersion(migrations, prop); return version ? set(acc, prop, version) : acc; }, {}), + coreMigrationVersion: kibanaVersion, }; } +/** + * Converts a single-namespace object to a multi-namespace object. This primarily entails removing the `namespace` field and adding the + * `namespaces` field. + * + * If the object does not exist in the default namespace (undefined), its ID is also regenerated, and an "originId" is added to preserve + * legacy import/copy behavior. + */ +function convertNamespaceType(doc: SavedObjectUnsanitizedDoc) { + const { namespace, ...otherAttrs } = doc; + const additionalDocs: SavedObjectUnsanitizedDoc[] = []; + + // If this object exists in the default namespace, return it with the appropriate `namespaces` field without changing its ID. + if (namespace === undefined) { + return { + transformedDoc: { ...otherAttrs, namespaces: [DEFAULT_NAMESPACE_STRING] }, + additionalDocs, + }; + } + + const { id: originId, type } = otherAttrs; + const id = deterministicallyRegenerateObjectId(namespace, type, originId!); + if (namespace !== undefined) { + const legacyUrlAlias: SavedObjectUnsanitizedDoc = { + id: `${namespace}:${type}:${originId}`, + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: namespace, + targetType: type, + targetId: id, + }, + }; + additionalDocs.push(legacyUrlAlias); + } + return { + transformedDoc: { ...otherAttrs, id, originId, namespaces: [namespace] }, + additionalDocs, + }; +} + +/** + * Returns all applicable conversion transforms for a given object type. + */ +function getConversionTransforms(type: SavedObjectsType): Transform[] { + const { convertToMultiNamespaceTypeVersion } = type; + if (!convertToMultiNamespaceTypeVersion) { + return []; + } + return [ + { + version: convertToMultiNamespaceTypeVersion, + transform: convertNamespaceType, + transformType: 'convert', + }, + ]; +} + +/** + * Returns all applicable reference transforms for all object types. + */ +function getReferenceTransforms(typeRegistry: ISavedObjectTypeRegistry): Transform[] { + const transformMap = typeRegistry + .getAllTypes() + .filter((type) => type.convertToMultiNamespaceTypeVersion) + .reduce((acc, { convertToMultiNamespaceTypeVersion: version, name }) => { + const types = acc.get(version!) ?? new Set(); + return acc.set(version!, types.add(name)); + }, new Map>()); + + return Array.from(transformMap, ([version, types]) => ({ + version, + transform: (doc) => { + const { namespace, references } = doc; + if (namespace && references?.length) { + return { + transformedDoc: { + ...doc, + references: references.map(({ type, id, ...attrs }) => ({ + ...attrs, + type, + id: types.has(type) ? deterministicallyRegenerateObjectId(namespace, type, id) : id, + })), + }, + additionalDocs: [], + }; + } + return { transformedDoc: doc, additionalDocs: [] }; + }, + transformType: 'reference', + })); +} + +/** + * Transforms are sorted in ascending order by version. One version may contain multiple transforms; 'reference' transforms always run + * first, 'convert' transforms always run second, and 'migrate' transforms always run last. This is because: + * 1. 'convert' transforms get rid of the `namespace` field, which must be present for 'reference' transforms to function correctly. + * 2. 'migrate' transforms are defined by the consumer, and may change the object type or migrationVersion which resets the migration loop + * and could cause any remaining transforms for this version to be skipped. + */ +function transformComparator(a: Transform, b: Transform) { + const semver = Semver.compare(a.version, b.version); + if (semver !== 0) { + return semver; + } else if (a.transformType !== b.transformType) { + if (a.transformType === 'migrate') { + return 1; + } else if (b.transformType === 'migrate') { + return -1; + } else if (a.transformType === 'convert') { + return 1; + } else if (b.transformType === 'convert') { + return -1; + } + } + return 0; +} + /** * If a specific transform function fails, this tacks on a bit of information * about the document and transform that caused the failure. @@ -342,7 +670,7 @@ function wrapWithTry( throw new Error(`Invalid saved object returned from migration ${type}:${version}.`); } - return result; + return { transformedDoc: result, additionalDocs: [] }; } catch (error) { const failedTransform = `${type}:${version}`; const failedDoc = JSON.stringify(doc); @@ -354,32 +682,52 @@ function wrapWithTry( }; } +/** + * Determines whether or not a document has any pending transforms that should be applied based on its coreMigrationVersion field. + * Currently, only reference transforms qualify. + */ +function getHasPendingCoreMigrationVersionTransform( + doc: SavedObjectUnsanitizedDoc, + migrations: ActiveMigrations, + prop: string +) { + if (!migrations.hasOwnProperty(prop)) { + return false; + } + + const { latestCoreMigrationVersion } = migrations[prop]; + const { coreMigrationVersion } = doc; + return ( + latestCoreMigrationVersion && + (!coreMigrationVersion || Semver.gt(latestCoreMigrationVersion, coreMigrationVersion)) + ); +} + /** * Finds the first unmigrated property in the specified document. */ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) { return props(doc).find((p) => { - const latestVersion = propVersion(migrations, p); + const latestMigrationVersion = propVersion(migrations, p); const docVersion = propVersion(doc, p); - if (latestVersion === docVersion) { - return false; - } - // We verify that the version is not greater than the version supported by Kibana. // If we didn't, this would cause an infinite loop, as we'd be unable to migrate the property // but it would continue to show up as unmigrated. - // If we have a docVersion and the latestVersion is smaller than it or does not exist, + // If we have a docVersion and the latestMigrationVersion is smaller than it or does not exist, // we are dealing with a document that belongs to a future Kibana / plugin version. - if (docVersion && (!latestVersion || Semver.gt(docVersion, latestVersion))) { + if (docVersion && (!latestMigrationVersion || Semver.gt(docVersion, latestMigrationVersion))) { throw Boom.badData( `Document "${doc.id}" has property "${p}" which belongs to a more recent` + - ` version of Kibana [${docVersion}]. The last known version is [${latestVersion}]`, + ` version of Kibana [${docVersion}]. The last known version is [${latestMigrationVersion}]`, doc ); } - return true; + return ( + (latestMigrationVersion && latestMigrationVersion !== docVersion) || + getHasPendingCoreMigrationVersionTransform(doc, migrations, p) // If the object itself is up-to-date, check if its references are up-to-date too + ); }); } @@ -389,23 +737,42 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi function migrateProp( doc: SavedObjectUnsanitizedDoc, prop: string, - migrations: ActiveMigrations -): SavedObjectUnsanitizedDoc { + migrations: ActiveMigrations, + convertNamespaceTypes: boolean +): TransformResult { const originalType = doc.type; let migrationVersion = _.clone(doc.migrationVersion) || {}; - const typeChanged = () => !doc.hasOwnProperty(prop) || doc.type !== originalType; + let additionalDocs: SavedObjectUnsanitizedDoc[] = []; - for (const { version, transform } of applicableTransforms(migrations, doc, prop)) { - doc = transform(doc); - migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version); - doc.migrationVersion = _.clone(migrationVersion); + for (const { version, transform, transformType } of applicableTransforms(migrations, doc, prop)) { + const currentVersion = propVersion(doc, prop); + if (currentVersion && Semver.gt(currentVersion, version)) { + // the previous transform function increased the object's migrationVersion; break out of the loop + break; + } - if (typeChanged()) { + if (convertNamespaceTypes || (transformType !== 'convert' && transformType !== 'reference')) { + // migrate transforms are always applied, but conversion transforms and reference transforms are only applied during index migrations + const result = transform(doc); + doc = result.transformedDoc; + additionalDocs = [...additionalDocs, ...result.additionalDocs]; + } + if (transformType === 'reference') { + // regardless of whether or not the reference transform was applied, update the object's coreMigrationVersion + // this is needed to ensure that we don't have an endless migration loop + doc.coreMigrationVersion = version; + } else { + migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version); + doc.migrationVersion = _.clone(migrationVersion); + } + + if (doc.type !== originalType) { + // the transform function changed the object's type; break out of the loop break; } } - return doc; + return { transformedDoc: doc, additionalDocs }; } /** @@ -417,9 +784,14 @@ function applicableTransforms( prop: string ) { const minVersion = propVersion(doc, prop); + const minReferenceVersion = doc.coreMigrationVersion || '0.0.0'; const { transforms } = migrations[prop]; return minVersion - ? transforms.filter(({ version }) => Semver.gt(version, minVersion)) + ? transforms.filter(({ version, transformType }) => + transformType === 'reference' + ? Semver.gt(version, minReferenceVersion) + : Semver.gt(version, minVersion) + ) : transforms; } @@ -466,3 +838,14 @@ function assertNoDowngrades( ); } } + +/** + * Deterministically regenerates a saved object's ID based upon it's current namespace, type, and ID. This ensures that we can regenerate + * any existing object IDs without worrying about collisions if two objects that exist in different namespaces share an ID. It also ensures + * that we can later regenerate any inbound object references to match. + * + * @note This is only intended to be used when single-namespace object types are converted into multi-namespace object types. + */ +function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { + return uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS); // the uuidv5 namespace constant (uuidv5.DNS) is arbitrary +} diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 025730e71b923..32ecea94826ff 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -557,6 +557,7 @@ describe('ElasticIndex', () => { mappings, count, migrations, + kibanaVersion, }: any) { client.indices.get = jest.fn().mockReturnValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -570,7 +571,12 @@ describe('ElasticIndex', () => { }) ); - const hasMigrations = await Index.migrationsUpToDate(client, index, migrations); + const hasMigrations = await Index.migrationsUpToDate( + client, + index, + migrations, + kibanaVersion + ); return { hasMigrations }; } @@ -584,6 +590,7 @@ describe('ElasticIndex', () => { }, count: 0, migrations: { dashy: '2.3.4' }, + kibanaVersion: '7.10.0', }); expect(hasMigrations).toBeFalsy(); @@ -611,6 +618,7 @@ describe('ElasticIndex', () => { }, count: 2, migrations: {}, + kibanaVersion: '7.10.0', }); expect(hasMigrations).toBeTruthy(); @@ -652,6 +660,7 @@ describe('ElasticIndex', () => { }, count: 3, migrations: { dashy: '23.2.5' }, + kibanaVersion: '7.10.0', }); expect(hasMigrations).toBeFalsy(); @@ -677,6 +686,7 @@ describe('ElasticIndex', () => { bashy: '99.9.3', flashy: '3.4.5', }, + kibanaVersion: '7.10.0', }); function shouldClause(type: string, version: string) { @@ -702,6 +712,15 @@ describe('ElasticIndex', () => { shouldClause('dashy', '23.2.5'), shouldClause('bashy', '99.9.3'), shouldClause('flashy', '3.4.5'), + { + bool: { + must_not: { + term: { + coreMigrationVersion: '7.10.0', + }, + }, + }, + }, ], }, }, diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index c6c00a123295d..9cdec926a56ba 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -147,6 +147,7 @@ export async function migrationsUpToDate( client: MigrationEsClient, index: string, migrationVersion: SavedObjectsMigrationVersion, + kibanaVersion: string, retryCount: number = 10 ): Promise { try { @@ -165,18 +166,29 @@ export async function migrationsUpToDate( body: { query: { bool: { - should: Object.entries(migrationVersion).map(([type, latestVersion]) => ({ - bool: { - must: [ - { exists: { field: type } }, - { - bool: { - must_not: { term: { [`migrationVersion.${type}`]: latestVersion } }, + should: [ + ...Object.entries(migrationVersion).map(([type, latestVersion]) => ({ + bool: { + must: [ + { exists: { field: type } }, + { + bool: { + must_not: { term: { [`migrationVersion.${type}`]: latestVersion } }, + }, + }, + ], + }, + })), + { + bool: { + must_not: { + term: { + coreMigrationVersion: kibanaVersion, }, }, - ], + }, }, - })), + ], }, }, }, @@ -194,7 +206,7 @@ export async function migrationsUpToDate( await new Promise((r) => setTimeout(r, 1000)); - return await migrationsUpToDate(client, index, migrationVersion, retryCount - 1); + return await migrationsUpToDate(client, index, migrationVersion, kibanaVersion, retryCount - 1); } } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index e82e30ef30031..a8abc75114a96 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -24,6 +24,7 @@ describe('IndexMigrator', () => { batchSize: 10, client: elasticsearchClientMock.createElasticsearchClient(), index: '.kibana', + kibanaVersion: '7.10.0', log: loggingSystemMock.create().get(), mappingProperties: {}, pollInterval: 1, @@ -31,6 +32,7 @@ describe('IndexMigrator', () => { documentMigrator: { migrationVersion: {}, migrate: _.identity, + migrateAndConvert: _.identity, prepareMigrations: jest.fn(), }, serializer: new SavedObjectsSerializer(new SavedObjectTypeRegistry()), @@ -58,6 +60,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', + coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -78,6 +81,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, + coreMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -179,6 +183,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', + coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -200,6 +205,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, + coreMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -240,6 +246,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', + coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -261,6 +268,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, + coreMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -307,17 +315,15 @@ describe('IndexMigrator', () => { test('transforms all docs from the original index', async () => { let count = 0; const { client } = testOpts; - const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { - return { - ...doc, - attributes: { name: ++count }, - }; + const migrateAndConvertDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { + return [{ ...doc, attributes: { name: ++count } }]; }); testOpts.documentMigrator = { migrationVersion: { foo: '1.2.3' }, + migrate: jest.fn(), + migrateAndConvert: migrateAndConvertDoc, prepareMigrations: jest.fn(), - migrate: migrateDoc, }; withIndex(client, { @@ -331,14 +337,14 @@ describe('IndexMigrator', () => { await new IndexMigrator(testOpts).migrate(); expect(count).toEqual(2); - expect(migrateDoc).toHaveBeenCalledWith({ + expect(migrateAndConvertDoc).toHaveBeenNthCalledWith(1, { id: '1', type: 'foo', attributes: { name: 'Bar' }, migrationVersion: {}, references: [], }); - expect(migrateDoc).toHaveBeenCalledWith({ + expect(migrateAndConvertDoc).toHaveBeenNthCalledWith(2, { id: '2', type: 'foo', attributes: { name: 'Baz' }, @@ -363,14 +369,15 @@ describe('IndexMigrator', () => { test('rejects when the migration function throws an error', async () => { const { client } = testOpts; - const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { + const migrateAndConvertDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { throw new Error('error migrating document'); }); testOpts.documentMigrator = { migrationVersion: { foo: '1.2.3' }, + migrate: jest.fn(), + migrateAndConvert: migrateAndConvertDoc, prepareMigrations: jest.fn(), - migrate: migrateDoc, }; withIndex(client, { diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index bb341e6173aea..869729daab4f3 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -60,13 +60,14 @@ export class IndexMigrator { * Determines what action the migration system needs to take (none, patch, migrate). */ async function requiresMigration(context: Context): Promise { - const { client, alias, documentMigrator, dest, log } = context; + const { client, alias, documentMigrator, dest, kibanaVersion, log } = context; // Have all of our known migrations been run against the index? const hasMigrations = await Index.migrationsUpToDate( client, alias, - documentMigrator.migrationVersion + documentMigrator.migrationVersion, + kibanaVersion ); if (!hasMigrations) { @@ -184,7 +185,7 @@ async function migrateSourceToDest(context: Context) { await Index.write( client, dest.indexName, - await migrateRawDocs(serializer, documentMigrator.migrate, docs, log) + await migrateRawDocs(serializer, documentMigrator.migrateAndConvert, docs, log) ); } } diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 4e6c2d0ddfd5c..f3e4b67876b71 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -15,7 +15,9 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { - const transform = jest.fn((doc: any) => set(doc, 'attributes.name', 'HOI!')); + const transform = jest.fn((doc: any) => [ + set(_.cloneDeep(doc), 'attributes.name', 'HOI!'), + ]); const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, @@ -37,14 +39,30 @@ describe('migrateRawDocs', () => { }, ]); - expect(transform).toHaveBeenCalled(); + const obj1 = { + id: 'b', + type: 'a', + attributes: { name: 'AAA' }, + migrationVersion: {}, + references: [], + }; + const obj2 = { + id: 'd', + type: 'c', + attributes: { name: 'DDD' }, + migrationVersion: {}, + references: [], + }; + expect(transform).toHaveBeenCalledTimes(2); + expect(transform).toHaveBeenNthCalledWith(1, obj1); + expect(transform).toHaveBeenNthCalledWith(2, obj2); }); test('passes invalid docs through untouched and logs error', async () => { const logger = createSavedObjectsMigrationLoggerMock(); - const transform = jest.fn((doc: any) => - set(_.cloneDeep(doc), 'attributes.name', 'TADA') - ); + const transform = jest.fn((doc: any) => [ + set(_.cloneDeep(doc), 'attributes.name', 'TADA'), + ]); const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, @@ -63,23 +81,53 @@ describe('migrateRawDocs', () => { }, ]); - expect(transform.mock.calls).toEqual([ - [ - { - id: 'd', - type: 'c', - attributes: { - name: 'DDD', - }, - migrationVersion: {}, - references: [], - }, - ], - ]); + const obj2 = { + id: 'd', + type: 'c', + attributes: { name: 'DDD' }, + migrationVersion: {}, + references: [], + }; + expect(transform).toHaveBeenCalledTimes(1); + expect(transform).toHaveBeenCalledWith(obj2); expect(logger.error).toBeCalledTimes(1); }); + test('handles when one document is transformed into multiple documents', async () => { + const transform = jest.fn((doc: any) => [ + set(_.cloneDeep(doc), 'attributes.name', 'HOI!'), + { id: 'bar', type: 'foo', attributes: { name: 'baz' } }, + ]); + const result = await migrateRawDocs( + new SavedObjectsSerializer(new SavedObjectTypeRegistry()), + transform, + [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], + createSavedObjectsMigrationLoggerMock() + ); + + expect(result).toEqual([ + { + _id: 'a:b', + _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, + }, + { + _id: 'foo:bar', + _source: { type: 'foo', foo: { name: 'baz' }, references: [] }, + }, + ]); + + const obj = { + id: 'b', + type: 'a', + attributes: { name: 'AAA' }, + migrationVersion: {}, + references: [], + }; + expect(transform).toHaveBeenCalledTimes(1); + expect(transform).toHaveBeenCalledWith(obj); + }); + test('rejects when the transform function throws an error', async () => { const transform = jest.fn((doc: any) => { throw new Error('error during transform'); diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index 0f939cd08aff0..fd1b7db36b4eb 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -15,7 +15,7 @@ import { SavedObjectsSerializer, SavedObjectUnsanitizedDoc, } from '../../serialization'; -import { TransformFn } from './document_migrator'; +import { MigrateAndConvertFn } from './document_migrator'; import { SavedObjectsMigrationLogger } from '.'; /** @@ -28,21 +28,24 @@ import { SavedObjectsMigrationLogger } from '.'; */ export async function migrateRawDocs( serializer: SavedObjectsSerializer, - migrateDoc: TransformFn, + migrateDoc: MigrateAndConvertFn, rawDocs: SavedObjectsRawDoc[], log: SavedObjectsMigrationLogger ): Promise { const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc); const processedDocs = []; for (const raw of rawDocs) { - if (serializer.isRawSavedObject(raw)) { - const savedObject = serializer.rawToSavedObject(raw); + const options = { namespaceTreatment: 'lax' as 'lax' }; + if (serializer.isRawSavedObject(raw, options)) { + const savedObject = serializer.rawToSavedObject(raw, options); savedObject.migrationVersion = savedObject.migrationVersion || {}; processedDocs.push( - serializer.savedObjectToRaw({ - references: [], - ...(await migrateDocWithoutBlocking(savedObject)), - }) + ...(await migrateDocWithoutBlocking(savedObject)).map((attrs) => + serializer.savedObjectToRaw({ + references: [], + ...attrs, + }) + ) ); } else { log.error( @@ -63,8 +66,8 @@ export async function migrateRawDocs( * work in between each transform. */ function transformNonBlocking( - transform: TransformFn -): (doc: SavedObjectUnsanitizedDoc) => Promise { + transform: MigrateAndConvertFn +): (doc: SavedObjectUnsanitizedDoc) => Promise { // promises aren't enough to unblock the event loop return (doc: SavedObjectUnsanitizedDoc) => new Promise((resolve, reject) => { diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index a5aaff7df2dd2..62e455c2ddb69 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -32,6 +32,7 @@ export interface MigrationOpts { scrollDuration: string; client: MigrationEsClient; index: string; + kibanaVersion: string; log: Logger; mappingProperties: SavedObjectsTypeMappingDefinitions; documentMigrator: VersionedTransformer; @@ -54,6 +55,7 @@ export interface Context { source: Index.FullIndexInfo; dest: Index.FullIndexInfo; documentMigrator: VersionedTransformer; + kibanaVersion: string; log: SavedObjectsMigrationLogger; batchSize: number; pollInterval: number; @@ -78,6 +80,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { alias, source, dest, + kibanaVersion: opts.kibanaVersion, log: new MigrationLogger(log), batchSize: opts.batchSize, documentMigrator: opts.documentMigrator, diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 9311292a6a0ed..32c2536ab0296 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -6,6 +6,7 @@ Object { "migrationMappingPropertyHashes": Object { "amap": "510f1f0adb69830cf8a1c5ce2923ed82", "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", @@ -31,6 +32,9 @@ Object { }, }, }, + "coreMigrationVersion": Object { + "type": "keyword", + }, "migrationVersion": Object { "dynamic": "true", "type": "object", diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index aea29479d2af0..c8bc4b2e14123 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -90,6 +90,7 @@ export class KibanaMigrator { }: KibanaMigratorOptions) { this.client = client; this.kibanaConfig = kibanaConfig; + this.kibanaVersion = kibanaVersion; this.savedObjectsConfig = savedObjectsConfig; this.typeRegistry = typeRegistry; this.serializer = new SavedObjectsSerializer(this.typeRegistry); @@ -177,7 +178,7 @@ export class KibanaMigrator { transformRawDocs: (rawDocs: SavedObjectsRawDoc[]) => migrateRawDocs( this.serializer, - this.documentMigrator.migrate, + this.documentMigrator.migrateAndConvert, rawDocs, new MigrationLogger(this.log) ), @@ -192,6 +193,7 @@ export class KibanaMigrator { client: createMigrationEsClient(this.client, this.log, this.migrationsRetryDelay), documentMigrator: this.documentMigrator, index, + kibanaVersion: this.kibanaVersion, log: this.log, mappingProperties: indexMap[index].typeMappings, pollInterval: this.savedObjectsConfig.pollInterval, diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index b54d0222a1478..52b4f50d599d9 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -61,7 +61,7 @@ export interface SavedObjectMigrationContext { /** * A map of {@link SavedObjectMigrationFn | migration functions} to be used for a given type. - * The map's keys must be valid semver versions. + * The map's keys must be valid semver versions, and they cannot exceed the current Kibana version. * * For a given document, only migrations with a higher version number than that of the document will be applied. * Migrations are executed in order, starting from the lowest version and ending with the highest one. diff --git a/src/core/server/saved_objects/object_types/constants.ts b/src/core/server/saved_objects/object_types/constants.ts new file mode 100644 index 0000000000000..4e05c406c653f --- /dev/null +++ b/src/core/server/saved_objects/object_types/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * @internal + */ +export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; diff --git a/src/core/server/saved_objects/object_types/index.ts b/src/core/server/saved_objects/object_types/index.ts new file mode 100644 index 0000000000000..1a9bccdc17c28 --- /dev/null +++ b/src/core/server/saved_objects/object_types/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { LEGACY_URL_ALIAS_TYPE } from './constants'; +export { LegacyUrlAlias } from './types'; +export { registerCoreObjectTypes } from './registration'; diff --git a/src/core/server/saved_objects/object_types/registration.test.ts b/src/core/server/saved_objects/object_types/registration.test.ts new file mode 100644 index 0000000000000..9bd7b3d61e099 --- /dev/null +++ b/src/core/server/saved_objects/object_types/registration.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { LEGACY_URL_ALIAS_TYPE } from './constants'; +import { registerCoreObjectTypes } from './registration'; + +describe('Core saved object types registration', () => { + describe('#registerCoreObjectTypes', () => { + it('registers all expected types', () => { + const typeRegistry = typeRegistryMock.create(); + registerCoreObjectTypes(typeRegistry); + + expect(typeRegistry.registerType).toHaveBeenCalledTimes(1); + expect(typeRegistry.registerType).toHaveBeenCalledWith( + expect.objectContaining({ name: LEGACY_URL_ALIAS_TYPE }) + ); + }); + }); +}); diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts new file mode 100644 index 0000000000000..82562ac53a109 --- /dev/null +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { LEGACY_URL_ALIAS_TYPE } from './constants'; +import { ISavedObjectTypeRegistry, SavedObjectsType, SavedObjectTypeRegistry } from '..'; + +const legacyUrlAliasType: SavedObjectsType = { + name: LEGACY_URL_ALIAS_TYPE, + namespaceType: 'agnostic', + mappings: { + dynamic: false, // we aren't querying or aggregating over this data, so we don't need to specify any fields + properties: {}, + }, + hidden: true, +}; + +/** + * @internal + */ +export function registerCoreObjectTypes( + typeRegistry: ISavedObjectTypeRegistry & Pick +) { + typeRegistry.registerType(legacyUrlAliasType); +} diff --git a/src/core/server/saved_objects/object_types/types.ts b/src/core/server/saved_objects/object_types/types.ts new file mode 100644 index 0000000000000..8391311cbefdf --- /dev/null +++ b/src/core/server/saved_objects/object_types/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * @internal + */ +export interface LegacyUrlAlias { + targetNamespace: string; + targetType: string; + targetId: string; + lastResolved?: string; + resolveCounter?: number; + disabled?: boolean; +} diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index eee9b9b1a0bff..6d57eaa3777e6 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -29,6 +29,7 @@ export const registerBulkCreateRoute = (router: IRouter, { coreUsageData }: Rout attributes: schema.recordOf(schema.string(), schema.any()), version: schema.maybe(schema.string()), migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + coreMigrationVersion: schema.maybe(schema.string()), references: schema.maybe( schema.arrayOf( schema.object({ diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index e486580320da9..fd256abac3526 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -29,6 +29,7 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep body: schema.object({ attributes: schema.recordOf(schema.string(), schema.any()), migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + coreMigrationVersion: schema.maybe(schema.string()), references: schema.maybe( schema.arrayOf( schema.object({ @@ -45,12 +46,25 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references, initialNamespaces } = req.body; + const { + attributes, + migrationVersion, + coreMigrationVersion, + references, + initialNamespaces, + } = req.body; const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsCreate({ request: req }).catch(() => {}); - const options = { id, overwrite, migrationVersion, references, initialNamespaces }; + const options = { + id, + overwrite, + migrationVersion, + coreMigrationVersion, + references, + initialNamespaces, + }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 67a4e305e87ad..412dd0e7ffbc0 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -12,6 +12,7 @@ import { Logger } from '../../logging'; import { SavedObjectConfig } from '../saved_objects_config'; import { IKibanaMigrator } from '../migrations'; import { registerGetRoute } from './get'; +import { registerResolveRoute } from './resolve'; import { registerCreateRoute } from './create'; import { registerDeleteRoute } from './delete'; import { registerFindRoute } from './find'; @@ -41,6 +42,7 @@ export function registerRoutes({ const router = http.createRouter('/api/saved_objects/'); registerGetRoute(router, { coreUsageData }); + registerResolveRoute(router, { coreUsageData }); registerCreateRoute(router, { coreUsageData }); registerDeleteRoute(router, { coreUsageData }); registerFindRoute(router, { coreUsageData }); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts new file mode 100644 index 0000000000000..5ddeb29b8c2d5 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import supertest from 'supertest'; +import { registerResolveRoute } from '../resolve'; +import { ContextService } from '../../../context'; +import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; +import { CoreUsageStatsClient } from '../../../core_usage_data'; +import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; +import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; +import { HttpService, InternalHttpServiceSetup } from '../../../http'; +import { createHttpServer, createCoreContext } from '../../../http/test_utils'; +import { coreMock } from '../../../mocks'; + +const coreId = Symbol('core'); + +describe('GET /api/saved_objects/resolve/{type}/{id}', () => { + let server: HttpService; + let httpSetup: InternalHttpServiceSetup; + let handlerContext: ReturnType; + let savedObjectsClient: ReturnType; + let coreUsageStatsClient: jest.Mocked; + + beforeEach(async () => { + const coreContext = createCoreContext({ coreId }); + server = createHttpServer(coreContext); + + const contextService = new ContextService(coreContext); + httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), + }); + + handlerContext = coreMock.createRequestHandlerContext(); + savedObjectsClient = handlerContext.savedObjects.client; + + httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { + return handlerContext; + }); + + const router = httpSetup.createRouter('/api/saved_objects/'); + coreUsageStatsClient = coreUsageStatsClientMock.create(); + coreUsageStatsClient.incrementSavedObjectsResolve.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail + const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); + registerResolveRoute(router, { coreUsageData }); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const clientResponse = { + saved_object: { + id: 'logstash-*', + title: 'logstash-*', + type: 'logstash-type', + attributes: {}, + timeFieldName: '@timestamp', + notExpandable: true, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + savedObjectsClient.resolve.mockResolvedValue(clientResponse); + + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/resolve/index-pattern/logstash-*') + .expect(200); + + expect(result.body).toEqual(clientResponse); + }); + + it('calls upon savedObjectClient.resolve', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/resolve/index-pattern/logstash-*') + .expect(200); + + expect(savedObjectsClient.resolve).toHaveBeenCalled(); + + const args = savedObjectsClient.resolve.mock.calls[0]; + expect(args).toEqual(['index-pattern', 'logstash-*']); + }); +}); diff --git a/src/core/server/saved_objects/routes/resolve.ts b/src/core/server/saved_objects/routes/resolve.ts new file mode 100644 index 0000000000000..28a3f4b876467 --- /dev/null +++ b/src/core/server/saved_objects/routes/resolve.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { CoreUsageDataSetup } from '../../core_usage_data'; + +interface RouteDependencies { + coreUsageData: CoreUsageDataSetup; +} + +export const registerResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { + router.get( + { + path: '/resolve/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + + const usageStatsClient = coreUsageData.getClient(); + usageStatsClient.incrementSavedObjectsResolve({ request: req }).catch(() => {}); + + const result = await context.core.savedObjects.client.resolve(type, id); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 3c1e217dcc229..4a8caa7686606 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -25,8 +25,10 @@ import { httpServerMock } from '../http/http_server.mocks'; import { SavedObjectsClientFactoryProvider } from './service/lib'; import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure_es_version'; import { SavedObjectsRepository } from './service/lib/repository'; +import { registerCoreObjectTypes } from './object_types'; jest.mock('./service/lib/repository'); +jest.mock('./object_types'); describe('SavedObjectsService', () => { const createCoreContext = ({ @@ -67,6 +69,16 @@ describe('SavedObjectsService', () => { }); describe('#setup()', () => { + it('calls registerCoreObjectTypes', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + + const mockedRegisterCoreObjectTypes = registerCoreObjectTypes as jest.Mock; + expect(mockedRegisterCoreObjectTypes).not.toHaveBeenCalled(); + await soService.setup(createSetupDeps()); + expect(mockedRegisterCoreObjectTypes).toHaveBeenCalledTimes(1); + }); + describe('#setClientFactoryProvider', () => { it('registers the factory to the clientProvider', async () => { const coreContext = createCoreContext(); @@ -130,6 +142,7 @@ describe('SavedObjectsService', () => { describe('#registerType', () => { it('registers the type to the internal typeRegistry', async () => { + // we mocked registerCoreObjectTypes above, so this test case only reflects direct calls to the registerType method const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 6db4cf4f781b4..40c8c576b0eca 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -43,6 +43,7 @@ import { SavedObjectsImporter, ISavedObjectsImporter } from './import'; import { registerRoutes } from './routes'; import { ServiceStatus } from '../status'; import { calculateStatus$ } from './status'; +import { registerCoreObjectTypes } from './object_types'; /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to @@ -305,6 +306,8 @@ export class SavedObjectsService migratorPromise: this.migrator$.pipe(first()).toPromise(), }); + registerCoreObjectTypes(this.typeRegistry); + return { status$: calculateStatus$( this.migrator$.pipe(switchMap((migrator) => migrator.getStatus$())), diff --git a/src/core/server/saved_objects/serialization/index.ts b/src/core/server/saved_objects/serialization/index.ts index ba6115dbff3ae..3ffaf9cf1e7c8 100644 --- a/src/core/server/saved_objects/serialization/index.ts +++ b/src/core/server/saved_objects/serialization/index.ts @@ -15,6 +15,7 @@ export { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, SavedObjectsRawDocSource, } from './types'; export { SavedObjectsSerializer } from './serializer'; diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 4d0527aee01bc..b09fb1ab30c79 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -11,6 +11,7 @@ import { SavedObjectsSerializer } from './serializer'; import { SavedObjectsRawDoc } from './types'; import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { encodeVersion } from '../version'; +import { LEGACY_URL_ALIAS_TYPE } from '../object_types'; let typeRegistry = typeRegistryMock.create(); typeRegistry.isNamespaceAgnostic.mockReturnValue(true); @@ -131,6 +132,27 @@ describe('#rawToSavedObject', () => { expect(expected).toEqual(actual); }); + test('if specified it copies the _source.coreMigrationVersion property to coreMigrationVersion', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + coreMigrationVersion: '1.2.3', + }, + }); + expect(actual).toHaveProperty('coreMigrationVersion', '1.2.3'); + }); + + test(`if _source.coreMigrationVersion is unspecified it doesn't set coreMigrationVersion`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('coreMigrationVersion'); + }); + test(`if version is unspecified it doesn't set version`, () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', @@ -288,6 +310,7 @@ describe('#rawToSavedObject', () => { foo: '1.2.3', bar: '9.8.7', }, + coreMigrationVersion: '4.5.6', namespace: 'foo-namespace', updated_at: String(new Date()), references: [], @@ -412,6 +435,41 @@ describe('#rawToSavedObject', () => { test(`doesn't copy _source.namespace to namespace`, () => { expect(actual).not.toHaveProperty('namespace'); }); + + describe('with lax namespaceTreatment', () => { + const options = { namespaceTreatment: 'lax' as 'lax' }; + + test(`removes type prefix from _id and, and does not copy _source.namespace to namespace`, () => { + const _actual = multiNamespaceSerializer.rawToSavedObject(raw, options); + expect(_actual).toHaveProperty('id', 'bar'); + expect(_actual).not.toHaveProperty('namespace'); + }); + + test(`removes type and namespace prefix from _id, and copies _source.namespace to namespace`, () => { + const _id = `${raw._source.namespace}:${raw._id}`; + const _actual = multiNamespaceSerializer.rawToSavedObject({ ...raw, _id }, options); + expect(_actual).toHaveProperty('id', 'bar'); + expect(_actual).toHaveProperty('namespace', raw._source.namespace); // "baz" + }); + + test(`removes type and namespace prefix from _id when the namespace matches the type`, () => { + const _raw = createSampleDoc({ _id: 'foo:foo:bar', _source: { namespace: 'foo' } }); + const _actual = multiNamespaceSerializer.rawToSavedObject(_raw, options); + expect(_actual).toHaveProperty('id', 'bar'); + expect(_actual).toHaveProperty('namespace', 'foo'); + }); + + test(`does not remove the entire _id when the namespace matches the type`, () => { + // This is not a realistic/valid document, but we defensively check to ensure we aren't trimming the entire ID. + // In this test case, a multi-namespace document has a raw ID with the type prefix "foo:" and an object ID of "foo:" (no namespace + // prefix). This document *also* has a `namespace` field the same as the type, while it should not have a `namespace` field at all + // since it has no namespace prefix in its raw ID. + const _raw = createSampleDoc({ _id: 'foo:foo:', _source: { namespace: 'foo' } }); + const _actual = multiNamespaceSerializer.rawToSavedObject(_raw, options); + expect(_actual).toHaveProperty('id', 'foo:'); + expect(_actual).not.toHaveProperty('namespace'); + }); + }); }); describe('multi-namespace type with namespaces', () => { @@ -515,6 +573,25 @@ describe('#savedObjectToRaw', () => { expect(actual._source).not.toHaveProperty('migrationVersion'); }); + test('it copies the coreMigrationVersion property to _source.coreMigrationVersion', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + coreMigrationVersion: '1.2.3', + } as any); + + expect(actual._source).toHaveProperty('coreMigrationVersion', '1.2.3'); + }); + + test(`if unspecified it doesn't add coreMigrationVersion property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('coreMigrationVersion'); + }); + test('it decodes the version property to _seq_no and _primary_term', () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', @@ -841,6 +918,116 @@ describe('#isRawSavedObject', () => { }); }); + describe('multi-namespace type with a namespace', () => { + test('is true if the id is prefixed with type and the type matches', () => { + expect( + multiNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeTruthy(); + }); + + test('is false if the id is not prefixed', () => { + expect( + multiNamespaceSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed with type and namespace', () => { + expect( + multiNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is true if the id is prefixed with type and namespace, and namespaceTreatment is lax', () => { + const options = { namespaceTreatment: 'lax' as 'lax' }; + expect( + multiNamespaceSerializer.isRawSavedObject( + { + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }, + options + ) + ).toBeTruthy(); + }); + + test(`is false if the type prefix omits the :`, () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute is missing', () => { + expect( + multiNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + namespace: 'foo', + } as any, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute does not match the id', () => { + expect( + multiNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if there is no [type] attribute', () => { + expect( + multiNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); + describe('namespace-agnostic type with a namespace', () => { test('is true if the id is prefixed with type and the type matches', () => { expect( @@ -950,6 +1137,13 @@ describe('#generateRawId', () => { }); }); + describe('multi-namespace type with a namespace', () => { + test(`uses the id that is specified and doesn't prefix the namespace`, () => { + const id = multiNamespaceSerializer.generateRawId('foo', 'hello', 'world'); + expect(id).toEqual('hello:world'); + }); + }); + describe('namespace-agnostic type with a namespace', () => { test(`uses the id that is specified and doesn't prefix the namespace`, () => { const id = namespaceAgnosticSerializer.generateRawId('foo', 'hello', 'world'); @@ -957,3 +1151,24 @@ describe('#generateRawId', () => { }); }); }); + +describe('#generateRawLegacyUrlAliasId', () => { + describe(`returns expected value`, () => { + const expected = `${LEGACY_URL_ALIAS_TYPE}:foo:bar:baz`; + + test(`for single-namespace types`, () => { + const id = singleNamespaceSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz'); + expect(id).toEqual(expected); + }); + + test(`for multi-namespace types`, () => { + const id = multiNamespaceSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz'); + expect(id).toEqual(expected); + }); + + test(`for namespace-agnostic types`, () => { + const id = namespaceAgnosticSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz'); + expect(id).toEqual(expected); + }); + }); +}); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 7a1de0ed2c960..4e9c3b6be03cf 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -6,9 +6,14 @@ * Public License, v 1. */ +import { LEGACY_URL_ALIAS_TYPE } from '../object_types'; import { decodeVersion, encodeVersion } from '../version'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; -import { SavedObjectsRawDoc, SavedObjectSanitizedDoc } from './types'; +import { + SavedObjectsRawDoc, + SavedObjectSanitizedDoc, + SavedObjectsRawDocParseOptions, +} from './types'; /** * A serializer that can be used to manually convert {@link SavedObjectsRawDoc | raw} or @@ -30,42 +35,60 @@ export class SavedObjectsSerializer { /** * Determines whether or not the raw document can be converted to a saved object. * - * @param {SavedObjectsRawDoc} rawDoc - The raw ES document to be tested + * @param {SavedObjectsRawDoc} doc - The raw ES document to be tested + * @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document. */ - public isRawSavedObject(rawDoc: SavedObjectsRawDoc) { - const { type, namespace } = rawDoc._source; - const namespacePrefix = - namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; - return Boolean( - type && - rawDoc._id.startsWith(`${namespacePrefix}${type}:`) && - rawDoc._source.hasOwnProperty(type) - ); + public isRawSavedObject(doc: SavedObjectsRawDoc, options: SavedObjectsRawDocParseOptions = {}) { + const { namespaceTreatment = 'strict' } = options; + const { _id, _source } = doc; + const { type, namespace } = _source; + if (!type) { + return false; + } + const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id, namespaceTreatment); + return idMatchesPrefix && _source.hasOwnProperty(type); } /** * Converts a document from the format that is stored in elasticsearch to the saved object client format. * - * @param {SavedObjectsRawDoc} doc - The raw ES document to be converted to saved object format. + * @param {SavedObjectsRawDoc} doc - The raw ES document to be converted to saved object format. + * @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document. */ - public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { + public rawToSavedObject( + doc: SavedObjectsRawDoc, + options: SavedObjectsRawDocParseOptions = {} + ): SavedObjectSanitizedDoc { + const { namespaceTreatment = 'strict' } = options; const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId } = _source; + const { + type, + namespaces, + originId, + migrationVersion, + references, + coreMigrationVersion, + } = _source; const version = _seq_no != null || _primary_term != null ? encodeVersion(_seq_no!, _primary_term!) : undefined; + const { id, namespace } = this.trimIdPrefix(_source.namespace, type, _id, namespaceTreatment); + const includeNamespace = + namespace && (namespaceTreatment === 'lax' || this.registry.isSingleNamespace(type)); + const includeNamespaces = this.registry.isMultiNamespace(type); return { type, - id: this.trimIdPrefix(namespace, type, _id), - ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), - ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), + id, + ...(includeNamespace && { namespace }), + ...(includeNamespaces && { namespaces }), ...(originId && { originId }), attributes: _source[type], - references: _source.references || [], - ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), + references: references || [], + ...(migrationVersion && { migrationVersion }), + ...(coreMigrationVersion && { coreMigrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), }; @@ -89,6 +112,7 @@ export class SavedObjectsSerializer { updated_at, version, references, + coreMigrationVersion, } = savedObj; const source = { [type]: attributes, @@ -98,6 +122,7 @@ export class SavedObjectsSerializer { ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), + ...(coreMigrationVersion && { coreMigrationVersion }), ...(updated_at && { updated_at }), }; @@ -121,22 +146,77 @@ export class SavedObjectsSerializer { return `${namespacePrefix}${type}:${id}`; } - private trimIdPrefix(namespace: string | undefined, type: string, id: string) { + /** + * Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias. + * + * @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 generateRawLegacyUrlAliasId(namespace: string, type: string, id: string) { + return `${LEGACY_URL_ALIAS_TYPE}:${namespace}:${type}:${id}`; + } + + /** + * Given a document's source namespace, type, and raw ID, trim the ID prefix (based on the namespaceType), returning the object ID and the + * detected namespace. A single-namespace object is only considered to exist in a namespace if its raw ID is prefixed by that *and* it has + * the namespace field in its source. + */ + private trimIdPrefix( + sourceNamespace: string | undefined, + type: string, + id: string, + namespaceTreatment: 'strict' | 'lax' + ) { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); - const namespacePrefix = - namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; - const prefix = `${namespacePrefix}${type}:`; + const { prefix, idMatchesPrefix, namespace } = this.parseIdPrefix( + sourceNamespace, + type, + id, + namespaceTreatment + ); + return { + id: idMatchesPrefix ? id.slice(prefix.length) : id, + namespace, + }; + } - if (!id.startsWith(prefix)) { - return id; + private parseIdPrefix( + sourceNamespace: string | undefined, + type: string, + id: string, + namespaceTreatment: 'strict' | 'lax' + ) { + let prefix: string; // the prefix that is used to validate this raw object ID + let namespace: string | undefined; // the namespace that is in the raw object ID (only for single-namespace objects) + const parseFlexibly = namespaceTreatment === 'lax' && this.registry.isMultiNamespace(type); + if (sourceNamespace && (this.registry.isSingleNamespace(type) || parseFlexibly)) { + prefix = `${sourceNamespace}:${type}:`; + if (parseFlexibly && !checkIdMatchesPrefix(id, prefix)) { + prefix = `${type}:`; + } else { + // this is either a single-namespace object, or is being converted into a multi-namespace object + namespace = sourceNamespace; + } + } else { + // there is no source namespace, OR there is a source namespace but this is not a single-namespace object + prefix = `${type}:`; } - return id.slice(prefix.length); + return { + prefix, + idMatchesPrefix: checkIdMatchesPrefix(id, prefix), + namespace, + }; } } +function checkIdMatchesPrefix(id: string, prefix: string) { + return id.startsWith(prefix) && id.length > prefix.length; +} + function assertNonEmptyString(value: string, name: string) { if (!value || typeof value !== 'string') { throw new TypeError(`Expected "${value}" to be a ${name}`); diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 95deedbb7d9c0..5de168a08f1db 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -43,6 +43,7 @@ interface SavedObjectDoc { namespace?: string; namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; + coreMigrationVersion?: string; version?: string; updated_at?: string; originId?: string; @@ -68,3 +69,19 @@ export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial * @public */ export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable; + +/** + * Options that can be specified when using the saved objects serializer to parse a raw document. + * + * @public + */ +export interface SavedObjectsRawDocParseOptions { + /** + * Optional setting to allow for lax handling of the raw document ID and namespace field. This is needed when a previously + * single-namespace object type is converted to a multi-namespace object type, and it is only intended to be used during upgrade + * migrations. + * + * If not specified, the default treatment is `strict`. + */ + namespaceTreatment?: 'strict' | 'lax'; +} diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index 6b00816e4c17b..3f2a2d677c42d 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -8,7 +8,7 @@ import { includedFields } from './included_fields'; -const BASE_FIELD_COUNT = 9; +const BASE_FIELD_COUNT = 10; describe('includedFields', () => { it('returns undefined if fields are not provided', () => { @@ -32,6 +32,7 @@ Array [ "type", "references", "migrationVersion", + "coreMigrationVersion", "updated_at", "originId", "foo", @@ -66,6 +67,7 @@ Array [ "type", "references", "migrationVersion", + "coreMigrationVersion", "updated_at", "originId", "foo", diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index 3e11d8d8ad4ef..7aaca2caf003f 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -30,6 +30,7 @@ export function includedFields(type: string | string[] = '*', fields?: string[] .concat('type') .concat('references') .concat('migrationVersion') + .concat('coreMigrationVersion') .concat('updated_at') .concat('originId') .concat(fields); // v5 compatibility diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index f5b7d30aee4fd..93e73f3255b87 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -17,6 +17,7 @@ const create = (): jest.Mocked => ({ bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index d8cdec1e0b8a5..216e1c4bd2d3c 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -13,6 +13,7 @@ import { ALL_NAMESPACES_STRING } from './utils'; import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; @@ -44,6 +45,7 @@ describe('SavedObjectsRepository', () => { const mockVersionProps = { _seq_no: 1, _primary_term: 1 }; const mockVersion = encodeHitVersion(mockVersionProps); + const KIBANA_VERSION = '2.0.0'; const CUSTOM_INDEX_TYPE = 'customIndex'; const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; const MULTI_NAMESPACE_TYPE = 'shareableType'; @@ -142,7 +144,7 @@ describe('SavedObjectsRepository', () => { const documentMigrator = new DocumentMigrator({ typeRegistry: registry, - kibanaVersion: '2.0.0', + kibanaVersion: KIBANA_VERSION, log: {}, }); @@ -216,6 +218,7 @@ describe('SavedObjectsRepository', () => { rawToSavedObject: jest.fn(), savedObjectToRaw: jest.fn(), generateRawId: jest.fn(), + generateRawLegacyUrlAliasId: jest.fn(), trimIdPrefix: jest.fn(), }; const _serializer = new SavedObjectsSerializer(registry); @@ -501,6 +504,7 @@ describe('SavedObjectsRepository', () => { const expectSuccessResult = (obj) => ({ ...obj, migrationVersion: { [obj.type]: '1.1.1' }, + coreMigrationVersion: KIBANA_VERSION, version: mockVersion, namespaces: obj.namespaces ?? [obj.namespace ?? 'default'], ...mockTimestampFields, @@ -954,6 +958,7 @@ describe('SavedObjectsRepository', () => { ...response.items[0].create, _source: { ...response.items[0].create._source, + coreMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation namespaces: response.items[0].create._source.namespaces, }, _id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/), @@ -962,6 +967,7 @@ describe('SavedObjectsRepository', () => { ...response.items[1].create, _source: { ...response.items[1].create._source, + coreMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation namespaces: response.items[1].create._source.namespaces, }, }); @@ -2140,6 +2146,7 @@ describe('SavedObjectsRepository', () => { references, namespaces: [namespace ?? 'default'], migrationVersion: { [type]: '1.1.1' }, + coreMigrationVersion: KIBANA_VERSION, }); }); }); @@ -2724,6 +2731,7 @@ describe('SavedObjectsRepository', () => { 'type', 'references', 'migrationVersion', + 'coreMigrationVersion', 'updated_at', 'originId', 'title', @@ -3254,6 +3262,231 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#resolve', () => { + const type = 'index-pattern'; + const id = 'logstash-*'; + const aliasTargetId = 'some-other-id'; // only used for 'aliasMatch' and 'conflict' outcomes + const namespace = 'foo-namespace'; + + const getMockAliasDocument = (resolveCounter) => ({ + body: { + get: { + _source: { + [LEGACY_URL_ALIAS_TYPE]: { + targetId: aliasTargetId, + ...(resolveCounter && { resolveCounter }), + // other fields are not used by the repository + }, + }, + }, + }, + }); + + describe('outcomes', () => { + describe('error', () => { + const expectNotFoundError = async (type, id, options) => { + await expect(savedObjectsRepository.resolve(type, id, options)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; + + it('because type is invalid', async () => { + await expectNotFoundError('unknownType', id); + expect(client.update).not.toHaveBeenCalled(); + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + }); + + it('because type is hidden', async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(client.update).not.toHaveBeenCalled(); + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + }); + + it('because alias is not used and actual object is not found', async () => { + const options = { namespace: undefined }; + const response = { found: false }; + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target + ); + + await expectNotFoundError(type, id, options); + expect(client.update).not.toHaveBeenCalled(); + expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target + expect(client.mget).not.toHaveBeenCalled(); + }); + + it('because actual object and alias object are both not found', async () => { + const options = { namespace }; + const objectResults = [ + { type, id, found: false }, + { type, id: aliasTargetId, found: false }, + ]; + client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object + const response = getMockMgetResponse(objectResults, options.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target + ); + + await expectNotFoundError(type, id, options); + expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + }); + }); + + describe('exactMatch', () => { + it('because namespace is undefined', async () => { + const options = { namespace: undefined }; + const response = getMockGetResponse({ type, id }); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target + ); + + const result = await savedObjectsRepository.resolve(type, id, options); + expect(client.update).not.toHaveBeenCalled(); + expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target + expect(client.mget).not.toHaveBeenCalled(); + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id }), + outcome: 'exactMatch', + }); + }); + + describe('because alias is not used', () => { + const expectExactMatchResult = async (aliasResult) => { + const options = { namespace }; + client.update.mockResolvedValueOnce(aliasResult); // for alias object + const response = getMockGetResponse({ type, id }, options.namespace); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target + ); + + const result = await savedObjectsRepository.resolve(type, id, options); + expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target + expect(client.mget).not.toHaveBeenCalled(); + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id }), + outcome: 'exactMatch', + }); + }; + + it('since alias call resulted in 404', async () => { + await expectExactMatchResult({ statusCode: 404 }); + }); + + it('since alias is not found', async () => { + await expectExactMatchResult({ body: { get: { found: false } } }); + }); + + it('since alias is disabled', async () => { + await expectExactMatchResult({ + body: { get: { _source: { [LEGACY_URL_ALIAS_TYPE]: { disabled: true } } } }, + }); + }); + }); + + describe('because alias is used', () => { + const expectExactMatchResult = async (objectResults) => { + const options = { namespace }; + client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object + const response = getMockMgetResponse(objectResults, options.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target + ); + + const result = await savedObjectsRepository.resolve(type, id, options); + expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id }), + outcome: 'exactMatch', + }); + }; + + it('but alias target is not found', async () => { + const objects = [ + { type, id }, + { type, id: aliasTargetId, found: false }, + ]; + await expectExactMatchResult(objects); + }); + + it('but alias target does not exist in this namespace', async () => { + const objects = [ + { type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + ]; + await expectExactMatchResult(objects); + }); + }); + }); + + describe('aliasMatch', () => { + const expectAliasMatchResult = async (objectResults) => { + const options = { namespace }; + client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object + const response = getMockMgetResponse(objectResults, options.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target + ); + + const result = await savedObjectsRepository.resolve(type, id, options); + expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id: aliasTargetId }), + outcome: 'aliasMatch', + }); + }; + + it('because actual target is not found', async () => { + const objects = [ + { type, id, found: false }, + { type, id: aliasTargetId }, + ]; + await expectAliasMatchResult(objects); + }); + + it('because actual target does not exist in this namespace', async () => { + const objects = [ + { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + ]; + await expectAliasMatchResult(objects); + }); + }); + + describe('conflict', () => { + it('because actual target and alias target are both found', async () => { + const options = { namespace }; + const objectResults = [ + { type, id }, // correct namespace field is added by getMockMgetResponse + { type, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + ]; + client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object + const response = getMockMgetResponse(objectResults, options.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target + ); + + const result = await savedObjectsRepository.resolve(type, id, options); + expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id }), + outcome: 'conflict', + }); + }); + }); + }); + }); + describe('#incrementCounter', () => { const type = 'config'; const id = 'one'; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 685760e81a2b7..2993d4234bd2e 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -47,6 +47,7 @@ import { SavedObjectsDeleteFromNamespacesResponse, SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, + SavedObjectsResolveResponse, } from '../saved_objects_client'; import { SavedObject, @@ -55,6 +56,7 @@ import { SavedObjectsMigrationVersion, MutatingOperationRefreshSetting, } from '../../types'; +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { @@ -920,25 +922,7 @@ export class SavedObjectsRepository { } as any) as SavedObject; } - const { originId, updated_at: updatedAt } = doc._source; - let namespaces = []; - if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = doc._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(doc._source.namespace), - ]; - } - - return { - id, - type, - namespaces, - ...(originId && { originId }), - ...(updatedAt && { updated_at: updatedAt }), - version: encodeHitVersion(doc), - attributes: doc._source[type], - references: doc._source.references || [], - migrationVersion: doc._source.migrationVersion, - }; + return this.getSavedObjectFromSource(type, id, doc); }), }; } @@ -978,26 +962,122 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt } = body._source; + return this.getSavedObjectFromSource(type, id, body); + } - let namespaces: string[] = []; - if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = body._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(body._source.namespace), - ]; + /** + * Resolves a single object, using any legacy URL alias if it exists + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { saved_object, outcome } + */ + async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return { - id, - type, - namespaces, - ...(originId && { originId }), - ...(updatedAt && { updated_at: updatedAt }), - version: encodeHitVersion(body), - attributes: body._source[type], - references: body._source.references || [], - migrationVersion: body._source.migrationVersion, - }; + const namespace = normalizeNamespace(options.namespace); + if (namespace === undefined) { + // legacy URL aliases cannot exist for the default namespace; just attempt to get the object + return this.resolveExactMatch(type, id, options); + } + + const rawAliasId = this._serializer.generateRawLegacyUrlAliasId(namespace, type, id); + const time = this._getCurrentTime(); + + // retrieve the alias, and if it is not disabled, update it + const aliasResponse = await this.client.update( + { + id: rawAliasId, + index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE), + refresh: false, + _source: 'true', + body: { + script: { + source: ` + if (ctx._source[params.type].disabled != true) { + if (ctx._source[params.type].resolveCounter == null) { + ctx._source[params.type].resolveCounter = 1; + } + else { + ctx._source[params.type].resolveCounter += 1; + } + ctx._source[params.type].lastResolved = params.time; + ctx._source.updated_at = params.time; + } + `, + lang: 'painless', + params: { + type: LEGACY_URL_ALIAS_TYPE, + time, + }, + }, + }, + }, + { ignore: [404] } + ); + + if ( + aliasResponse.statusCode === 404 || + aliasResponse.body.get.found === false || + aliasResponse.body.get._source[LEGACY_URL_ALIAS_TYPE]?.disabled === true + ) { + // no legacy URL alias exists, or one exists but it's disabled; just attempt to get the object + return this.resolveExactMatch(type, id, options); + } + const legacyUrlAlias: LegacyUrlAlias = aliasResponse.body.get._source[LEGACY_URL_ALIAS_TYPE]; + const objectIndex = this.getIndexForType(type); + const bulkGetResponse = await this.client.mget( + { + body: { + docs: [ + { + // attempt to find an exact match for the given ID + _id: this._serializer.generateRawId(namespace, type, id), + _index: objectIndex, + }, + { + // also attempt to find a match for the legacy URL alias target ID + _id: this._serializer.generateRawId(namespace, type, legacyUrlAlias.targetId), + _index: objectIndex, + }, + ], + }, + }, + { ignore: [404] } + ); + + const exactMatchDoc = bulkGetResponse?.body.docs[0]; + const aliasMatchDoc = bulkGetResponse?.body.docs[1]; + const foundExactMatch = + exactMatchDoc.found && this.rawDocExistsInNamespace(exactMatchDoc, namespace); + const foundAliasMatch = + aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace); + + if (foundExactMatch && foundAliasMatch) { + return { + saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), + outcome: 'conflict', + }; + } else if (foundExactMatch) { + return { + saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), + outcome: 'exactMatch', + }; + } else if (foundAliasMatch) { + return { + saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), + outcome: 'aliasMatch', + }; + } + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } /** @@ -1718,7 +1798,7 @@ export class SavedObjectsRepository { if (this._registry.isSingleNamespace(type)) { savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)]; } - return omit(savedObject, 'namespace') as SavedObject; + return omit(savedObject, ['namespace']) as SavedObject; } /** @@ -1814,6 +1894,43 @@ export class SavedObjectsRepository { } return body as SavedObjectsRawDoc; } + + private getSavedObjectFromSource( + type: string, + id: string, + doc: { _seq_no: number; _primary_term: number; _source: SavedObjectsRawDocSource } + ): SavedObject { + const { originId, updated_at: updatedAt } = doc._source; + + let namespaces: string[] = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = doc._source.namespaces ?? [ + SavedObjectsUtils.namespaceIdToString(doc._source.namespace), + ]; + } + + return { + id, + type, + namespaces, + ...(originId && { originId }), + ...(updatedAt && { updated_at: updatedAt }), + version: encodeHitVersion(doc), + attributes: doc._source[type], + references: doc._source.references || [], + migrationVersion: doc._source.migrationVersion, + coreMigrationVersion: doc._source.coreMigrationVersion, + }; + } + + private async resolveExactMatch( + type: string, + id: string, + options: SavedObjectsBaseOptions + ): Promise> { + const object = await this.get(type, id, options); + return { saved_object: object, outcome: 'exactMatch' }; + } } function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index 75269d3a77f65..2dd2bcce7a245 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -20,6 +20,7 @@ const create = () => bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 5cee6bc274f9b..e6409fb853bd8 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -115,6 +115,22 @@ test(`#get`, async () => { expect(result).toBe(returnValue); }); +test(`#resolve`, async () => { + const returnValue = Symbol(); + const mockRepository = { + resolve: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const options = Symbol(); + const result = await client.resolve(type, id, options); + + expect(mockRepository.resolve).toHaveBeenCalledWith(type, id, options); + expect(result).toBe(returnValue); +}); + test(`#update`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index ca1d404e010bd..d17f6b082096f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -34,6 +34,16 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { version?: string; /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** + * A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current + * Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the + * current Kibana version, it will result in an error. + * + * @remarks + * Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion` + * field set and you want to create it again. + */ + coreMigrationVersion?: string; references?: SavedObjectReference[]; /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; @@ -60,6 +70,16 @@ export interface SavedObjectsBulkCreateObject { references?: SavedObjectReference[]; /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** + * A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current + * Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the + * current Kibana version, it will result in an error. + * + * @remarks + * Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion` + * field set and you want to create it again. + */ + coreMigrationVersion?: string; /** Optional ID of the original saved object, if this object's `id` was regenerated */ originId?: string; /** @@ -273,6 +293,24 @@ export interface SavedObjectsUpdateResponse references: SavedObjectReference[] | undefined; } +/** + * + * @public + */ +export interface SavedObjectsResolveResponse { + saved_object: SavedObject; + /** + * The outcome for a successful `resolve` call is one of the following values: + * + * * `'exactMatch'` -- One document exactly matched the given ID. + * * `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different + * than the given ID. + * * `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the + * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + */ + outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; +} + /** * * @public @@ -379,6 +417,21 @@ export class SavedObjectsClient { return await this._repository.get(type, id, options); } + /** + * Resolves a single object, using any legacy URL alias if it exists + * + * @param type - The type of SavedObject to retrieve + * @param id - The ID of the SavedObject to retrieve + * @param options + */ + async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + return await this._repository.resolve(type, id, options); + } + /** * Updates an SavedObject * diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index cbd8b415d9d31..7fab03aab4d0f 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -241,6 +241,41 @@ export interface SavedObjectsType { * An optional map of {@link SavedObjectMigrationFn | migrations} or a function returning a map of {@link SavedObjectMigrationFn | migrations} to be used to migrate the type. */ migrations?: SavedObjectMigrationMap | (() => SavedObjectMigrationMap); + /** + * If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + * + * Requirements: + * + * 1. This string value must be a valid semver version + * 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`} + * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} + * + * Example of a single-namespace type in 7.10: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'single', + * mappings: {...} + * } + * ``` + * + * Example after converting to a multi-namespace type in 7.11: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'multiple', + * mappings: {...}, + * convertToMultiNamespaceTypeVersion: '7.11.0' + * } + * ``` + * + * Note: a migration function can be optionally specified for the same version. + */ + convertToMultiNamespaceTypeVersion?: string; /** * An optional {@link SavedObjectsTypeManagementDefinition | saved objects management section} definition for the type. */ diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 3a8d7f4f0b0ff..d0ba6aa1900c7 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -680,6 +680,20 @@ export interface CoreUsageStats { // (undocumented) 'apiCalls.savedObjectsImport.total'?: number; // (undocumented) + 'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolve.namespace.custom.total'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolve.namespace.default.total'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolve.total'?: number; + // (undocumented) 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no'?: number; // (undocumented) 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes'?: number; @@ -2033,6 +2047,7 @@ export type SafeRouteMethod = 'get' | 'options'; // @public (undocumented) export interface SavedObject { attributes: T; + coreMigrationVersion?: string; // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -2116,6 +2131,7 @@ export interface SavedObjectsBaseOptions { export interface SavedObjectsBulkCreateObject { // (undocumented) attributes: T; + coreMigrationVersion?: string; // (undocumented) id?: string; initialNamespaces?: string[]; @@ -2206,6 +2222,7 @@ export class SavedObjectsClient { find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; + resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2276,6 +2293,7 @@ export interface SavedObjectsCoreFieldMapping { // @public (undocumented) export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { + coreMigrationVersion?: string; id?: string; initialNamespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; @@ -2711,6 +2729,11 @@ export interface SavedObjectsRawDoc { _source: SavedObjectsRawDocSource; } +// @public +export interface SavedObjectsRawDocParseOptions { + namespaceTreatment?: 'strict' | 'lax'; +} + // @public (undocumented) export interface SavedObjectsRemoveReferencesToOptions extends SavedObjectsBaseOptions { refresh?: boolean; @@ -2741,6 +2764,7 @@ export class SavedObjectsRepository { get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; + resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2758,13 +2782,21 @@ export interface SavedObjectsResolveImportErrorsOptions { retries: SavedObjectsImportRetry[]; } +// @public (undocumented) +export interface SavedObjectsResolveResponse { + outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + // (undocumented) + saved_object: SavedObject; +} + // @public export class SavedObjectsSerializer { // @internal constructor(registry: ISavedObjectTypeRegistry); generateRawId(namespace: string | undefined, type: string, id: string): string; - isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean; - rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; + generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string; + isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean; + rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; savedObjectToRaw(savedObj: SavedObjectSanitizedDoc): SavedObjectsRawDoc; } @@ -2799,6 +2831,7 @@ export interface SavedObjectStatusMeta { // @public (undocumented) export interface SavedObjectsType { convertToAliasScript?: string; + convertToMultiNamespaceTypeVersion?: string; hidden: boolean; indexPattern?: string; management?: SavedObjectsTypeManagementDefinition; diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 38b8ad0fc5325..c19f1febc97b1 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -82,6 +82,8 @@ export interface SavedObject { references: SavedObjectReference[]; /** {@inheritdoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** A semver value that is used when upgrading objects between Kibana versions. */ + coreMigrationVersion?: string; /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ namespaces?: string[]; /** diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 15594dc80c888..84a82511d5a5e 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -1118,7 +1118,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index 1bc4c70e77064..9b83cdd69a545 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -154,6 +154,13 @@ export function getCoreUsageCollector( 'apiCalls.savedObjectsGet.namespace.custom.total': { type: 'long' }, 'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.yes': { type: 'long' }, 'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.no': { type: 'long' }, + 'apiCalls.savedObjectsResolve.total': { type: 'long' }, + 'apiCalls.savedObjectsResolve.namespace.default.total': { type: 'long' }, + 'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no': { type: 'long' }, + 'apiCalls.savedObjectsResolve.namespace.custom.total': { type: 'long' }, + 'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no': { type: 'long' }, 'apiCalls.savedObjectsUpdate.total': { type: 'long' }, 'apiCalls.savedObjectsUpdate.namespace.default.total': { type: 'long' }, 'apiCalls.savedObjectsUpdate.namespace.default.kibanaRequest.yes': { type: 'long' }, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index bed142f165d64..50a08d96de951 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -3864,6 +3864,27 @@ "apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.no": { "type": "long" }, + "apiCalls.savedObjectsResolve.total": { + "type": "long" + }, + "apiCalls.savedObjectsResolve.namespace.default.total": { + "type": "long" + }, + "apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no": { + "type": "long" + }, + "apiCalls.savedObjectsResolve.namespace.custom.total": { + "type": "long" + }, + "apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no": { + "type": "long" + }, "apiCalls.savedObjectsUpdate.total": { "type": "long" }, diff --git a/test/api_integration/apis/saved_objects/bulk_create.ts b/test/api_integration/apis/saved_objects/bulk_create.ts index 903332a0a930f..a548172365b07 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.ts +++ b/test/api_integration/apis/saved_objects/bulk_create.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -21,6 +22,7 @@ export default function ({ getService }: FtrProviderContext) { attributes: { title: 'An existing visualization', }, + coreMigrationVersion: '1.2.3', }, { type: 'dashboard', @@ -32,6 +34,12 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('_bulk_create', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -65,6 +73,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, + coreMigrationVersion: KIBANA_VERSION, references: [], namespaces: ['default'], }, @@ -112,6 +121,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: { visualization: resp.body.saved_objects[0].migrationVersion.visualization, }, + coreMigrationVersion: KIBANA_VERSION, // updated from 1.2.3 to the latest kibana version }, { type: 'dashboard', @@ -126,6 +136,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, + coreMigrationVersion: KIBANA_VERSION, }, ], }); diff --git a/test/api_integration/apis/saved_objects/bulk_get.ts b/test/api_integration/apis/saved_objects/bulk_get.ts index e552c08a58cf0..46631225f8e8a 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.ts +++ b/test/api_integration/apis/saved_objects/bulk_get.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -30,6 +31,12 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('_bulk_get', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -58,6 +65,7 @@ export default function ({ getService }: FtrProviderContext) { resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], references: [ { @@ -87,6 +95,7 @@ export default function ({ getService }: FtrProviderContext) { }, namespaces: ['default'], migrationVersion: resp.body.saved_objects[2].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, references: [], }, ], diff --git a/test/api_integration/apis/saved_objects/create.ts b/test/api_integration/apis/saved_objects/create.ts index b1cd5a8dfdae4..551e082630e8f 100644 --- a/test/api_integration/apis/saved_objects/create.ts +++ b/test/api_integration/apis/saved_objects/create.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -15,6 +16,12 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('create', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -42,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) { id: resp.body.id, type: 'visualization', migrationVersion: resp.body.migrationVersion, + coreMigrationVersion: KIBANA_VERSION, updated_at: resp.body.updated_at, version: resp.body.version, attributes: { @@ -53,6 +61,21 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.migrationVersion).to.be.ok(); }); }); + + it('result should be updated to the latest coreMigrationVersion', async () => { + await supertest + .post(`/api/saved_objects/visualization`) + .send({ + attributes: { + title: 'My favorite vis', + }, + coreMigrationVersion: '1.2.3', + }) + .expect(200) + .then((resp) => { + expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION); + }); + }); }); describe('without kibana index', () => { @@ -86,6 +109,7 @@ export default function ({ getService }: FtrProviderContext) { id: resp.body.id, type: 'visualization', migrationVersion: resp.body.migrationVersion, + coreMigrationVersion: KIBANA_VERSION, updated_at: resp.body.updated_at, version: resp.body.version, attributes: { @@ -99,6 +123,21 @@ export default function ({ getService }: FtrProviderContext) { expect((await es.indices.exists({ index: '.kibana' })).body).to.be(true); }); + + it('result should have the latest coreMigrationVersion', async () => { + await supertest + .post(`/api/saved_objects/visualization`) + .send({ + attributes: { + title: 'My favorite vis', + }, + coreMigrationVersion: '1.2.3', + }) + .expect(200) + .then((resp) => { + expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION); + }); + }); }); }); } diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index a84f3050fdd17..a45191f24d872 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../ftr_provider_context'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; function ndjsonToObject(input: string) { return input.split('\n').map((str) => JSON.parse(str)); @@ -18,6 +19,12 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('export', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { describe('basic amount of saved objects', () => { before(() => esArchiver.load('saved_objects/basic')); @@ -312,6 +319,7 @@ export default function ({ getService }: FtrProviderContext) { }, id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, references: [ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -371,6 +379,7 @@ export default function ({ getService }: FtrProviderContext) { }, id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, references: [ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -435,6 +444,7 @@ export default function ({ getService }: FtrProviderContext) { }, id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, references: [ { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index a3ce70888049c..7aa4de86baa69 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { SavedObject } from '../../../../src/core/server'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -16,6 +17,12 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('find', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -39,6 +46,7 @@ export default function ({ getService }: FtrProviderContext) { }, score: 0, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], references: [ { @@ -134,6 +142,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], score: 0, references: [ @@ -170,6 +179,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], score: 0, references: [ @@ -187,6 +197,7 @@ export default function ({ getService }: FtrProviderContext) { }, id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['foo-ns'], references: [ { @@ -202,7 +213,6 @@ export default function ({ getService }: FtrProviderContext) { }, ], }); - expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); })); }); @@ -244,6 +254,7 @@ export default function ({ getService }: FtrProviderContext) { }, ], migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, updated_at: '2017-09-21T18:51:23.794Z', version: 'WzIsMV0=', }, diff --git a/test/api_integration/apis/saved_objects/get.ts b/test/api_integration/apis/saved_objects/get.ts index 7134917122177..ff47b9df218dc 100644 --- a/test/api_integration/apis/saved_objects/get.ts +++ b/test/api_integration/apis/saved_objects/get.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -15,6 +16,12 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('get', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -30,6 +37,7 @@ export default function ({ getService }: FtrProviderContext) { updated_at: '2017-09-21T18:51:23.794Z', version: resp.body.version, migrationVersion: resp.body.migrationVersion, + coreMigrationVersion: KIBANA_VERSION, attributes: { title: 'Count of requests', description: '', diff --git a/test/api_integration/apis/saved_objects/index.ts b/test/api_integration/apis/saved_objects/index.ts index 0e07b3c1ed060..2f63a4a7cce0a 100644 --- a/test/api_integration/apis/saved_objects/index.ts +++ b/test/api_integration/apis/saved_objects/index.ts @@ -12,15 +12,16 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('saved_objects', () => { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./migrations')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./update')); - loadTestFile(require.resolve('./bulk_update')); - loadTestFile(require.resolve('./migrations')); }); } diff --git a/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts b/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts new file mode 100644 index 0000000000000..e278bd3d50034 --- /dev/null +++ b/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export async function getKibanaVersion(getService: FtrProviderContext['getService']) { + const kibanaServer = getService('kibanaServer'); + const kibanaVersion = await kibanaServer.version.get(); + expect(typeof kibanaVersion).to.eql('string'); + expect(kibanaVersion.length).to.be.greaterThan(0); + return kibanaVersion; +} diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 9bb820b2f8414..0b06b675f60c0 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -10,10 +10,11 @@ * Smokescreen tests for core migration logic */ +import uuidv5 from 'uuid/v5'; import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; import expect from '@kbn/expect'; -import { ElasticsearchClient, SavedObjectMigrationMap, SavedObjectsType } from 'src/core/server'; +import { ElasticsearchClient, SavedObjectsType } from 'src/core/server'; import { SearchResponse } from '../../../../src/core/server/elasticsearch/client'; import { DocumentMigrator, @@ -28,6 +29,26 @@ import { } from '../../../../src/core/server/saved_objects'; import { FtrProviderContext } from '../../ftr_provider_context'; +const KIBANA_VERSION = '99.9.9'; +const FOO_TYPE: SavedObjectsType = { + name: 'foo', + hidden: false, + namespaceType: 'single', + mappings: { properties: {} }, +}; +const BAR_TYPE: SavedObjectsType = { + name: 'bar', + hidden: false, + namespaceType: 'single', + mappings: { properties: {} }, +}; +const BAZ_TYPE: SavedObjectsType = { + name: 'baz', + hidden: false, + namespaceType: 'single', + mappings: { properties: {} }, +}; + function getLogMock() { return { debug() {}, @@ -61,16 +82,22 @@ export default ({ getService }: FtrProviderContext) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations: Record = { - foo: { - '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), + const savedObjectTypes: SavedObjectsType[] = [ + { + ...FOO_TYPE, + migrations: { + '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), + }, }, - bar: { - '1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1), - '1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }), - '1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2), + { + ...BAR_TYPE, + migrations: { + '1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1), + '1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }), + '1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2), + }, }, - }; + ]; await createIndex({ esClient, index }); await createDocs({ esClient, index, docs: originalDocs }); @@ -107,7 +134,7 @@ export default ({ getService }: FtrProviderContext) => { const result = await migrateIndex({ esClient, index, - migrations, + savedObjectTypes, mappingProperties, obsoleteIndexTemplatePattern: 'migration_a*', }); @@ -129,13 +156,7 @@ export default ({ getService }: FtrProviderContext) => { }); // The docs in the original index are unchanged - expect(await fetchDocs(esClient, `${index}_1`)).to.eql([ - { id: 'bar:i', type: 'bar', bar: { nomnom: 33 } }, - { id: 'bar:o', type: 'bar', bar: { nomnom: 2 } }, - { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' } }, - { id: 'foo:a', type: 'foo', foo: { name: 'Foo A' } }, - { id: 'foo:e', type: 'foo', foo: { name: 'Fooey' } }, - ]); + expect(await fetchDocs(esClient, `${index}_1`)).to.eql(originalDocs.sort(sortByTypeAndId)); // The docs in the alias have been migrated expect(await fetchDocs(esClient, index)).to.eql([ @@ -145,6 +166,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:o', @@ -152,14 +174,22 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], + coreMigrationVersion: KIBANA_VERSION, + }, + { + id: 'baz:u', + type: 'baz', + baz: { title: 'Terrific!' }, + references: [], + coreMigrationVersion: KIBANA_VERSION, }, - { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [] }, { id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:e', @@ -167,6 +197,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, ]); }); @@ -185,28 +216,46 @@ export default ({ getService }: FtrProviderContext) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations: Record = { - foo: { - '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), + let savedObjectTypes: SavedObjectsType[] = [ + { + ...FOO_TYPE, + migrations: { + '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), + }, }, - bar: { - '1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1), - '1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }), - '1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2), + { + ...BAR_TYPE, + migrations: { + '1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1), + '1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }), + '1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2), + }, }, - }; + ]; await createIndex({ esClient, index }); await createDocs({ esClient, index, docs: originalDocs }); - await migrateIndex({ esClient, index, migrations, mappingProperties }); + await migrateIndex({ esClient, index, savedObjectTypes, mappingProperties }); // @ts-expect-error name doesn't exist on mynum type mappingProperties.bar.properties.name = { type: 'keyword' }; - migrations.foo['2.0.1'] = (doc) => set(doc, 'attributes.name', `${doc.attributes.name}v2`); - migrations.bar['2.3.4'] = (doc) => set(doc, 'attributes.name', `NAME ${doc.id}`); + savedObjectTypes = [ + { + ...FOO_TYPE, + migrations: { + '2.0.1': (doc) => set(doc, 'attributes.name', `${doc.attributes.name}v2`), + }, + }, + { + ...BAR_TYPE, + migrations: { + '2.3.4': (doc) => set(doc, 'attributes.name', `NAME ${doc.id}`), + }, + }, + ]; - await migrateIndex({ esClient, index, migrations, mappingProperties }); + await migrateIndex({ esClient, index, savedObjectTypes, mappingProperties }); // The index for the initial migration has not been destroyed... expect(await fetchDocs(esClient, `${index}_2`)).to.eql([ @@ -216,6 +265,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:o', @@ -223,6 +273,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:a', @@ -230,6 +281,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:e', @@ -237,6 +289,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, ]); @@ -248,6 +301,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 68, name: 'NAME i' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:o', @@ -255,6 +309,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 6, name: 'NAME o' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:a', @@ -262,6 +317,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:e', @@ -269,6 +325,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, ]); }); @@ -281,18 +338,21 @@ export default ({ getService }: FtrProviderContext) => { foo: { properties: { name: { type: 'text' } } }, }; - const migrations: Record = { - foo: { - '1.0.0': (doc) => set(doc, 'attributes.name', 'LOTR'), + const savedObjectTypes: SavedObjectsType[] = [ + { + ...FOO_TYPE, + migrations: { + '1.0.0': (doc) => set(doc, 'attributes.name', 'LOTR'), + }, }, - }; + ]; await createIndex({ esClient, index }); await createDocs({ esClient, index, docs: originalDocs }); const result = await Promise.all([ - migrateIndex({ esClient, index, migrations, mappingProperties }), - migrateIndex({ esClient, index, migrations, mappingProperties }), + migrateIndex({ esClient, index, savedObjectTypes, mappingProperties }), + migrateIndex({ esClient, index, savedObjectTypes, mappingProperties }), ]); // The polling instance and the migrating instance should both @@ -327,9 +387,170 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' }, references: [], + coreMigrationVersion: KIBANA_VERSION, }, ]); }); + + it('Correctly applies reference transforms and conversion transforms', async () => { + const index = '.migration-d'; + const originalDocs = [ + { id: 'foo:1', type: 'foo', foo: { name: 'Foo 1 default' } }, + { id: 'spacex:foo:1', type: 'foo', foo: { name: 'Foo 1 spacex' }, namespace: 'spacex' }, + { + id: 'bar:1', + type: 'bar', + bar: { nomnom: 1 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + }, + { + id: 'spacex:bar:1', + type: 'bar', + bar: { nomnom: 2 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 spacex' }], + namespace: 'spacex', + }, + { + id: 'baz:1', + type: 'baz', + baz: { title: 'Baz 1 default' }, + references: [{ type: 'bar', id: '1', name: 'Bar 1 default' }], + }, + { + id: 'spacex:baz:1', + type: 'baz', + baz: { title: 'Baz 1 spacex' }, + references: [{ type: 'bar', id: '1', name: 'Bar 1 spacex' }], + namespace: 'spacex', + }, + ]; + + const mappingProperties = { + foo: { properties: { name: { type: 'text' } } }, + bar: { properties: { nomnom: { type: 'integer' } } }, + baz: { properties: { title: { type: 'keyword' } } }, + }; + + const savedObjectTypes: SavedObjectsType[] = [ + { + ...FOO_TYPE, + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '1.0.0', + }, + { + ...BAR_TYPE, + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '2.0.0', + }, + BAZ_TYPE, // must be registered for reference transforms to be applied to objects of this type + ]; + + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); + + await migrateIndex({ + esClient, + index, + savedObjectTypes, + mappingProperties, + obsoleteIndexTemplatePattern: 'migration_a*', + }); + + // The docs in the original index are unchanged + expect(await fetchDocs(esClient, `${index}_1`)).to.eql(originalDocs.sort(sortByTypeAndId)); + + // The docs in the alias have been migrated + const migratedDocs = await fetchDocs(esClient, index); + + // each newly converted multi-namespace object in a non-default space has its ID deterministically regenerated, and a legacy-url-alias + // object is created which links the old ID to the new ID + const newFooId = uuidv5('spacex:foo:1', uuidv5.DNS); + const newBarId = uuidv5('spacex:bar:1', uuidv5.DNS); + + expect(migratedDocs).to.eql( + [ + { + id: 'foo:1', + type: 'foo', + foo: { name: 'Foo 1 default' }, + references: [], + namespaces: ['default'], + migrationVersion: { foo: '1.0.0' }, + coreMigrationVersion: KIBANA_VERSION, + }, + { + id: `foo:${newFooId}`, + type: 'foo', + foo: { name: 'Foo 1 spacex' }, + references: [], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { foo: '1.0.0' }, + coreMigrationVersion: KIBANA_VERSION, + }, + { + // new object + id: 'legacy-url-alias:spacex:foo:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newFooId, + targetNamespace: 'spacex', + targetType: 'foo', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: KIBANA_VERSION, + }, + { + id: 'bar:1', + type: 'bar', + bar: { nomnom: 1 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + namespaces: ['default'], + migrationVersion: { bar: '2.0.0' }, + coreMigrationVersion: KIBANA_VERSION, + }, + { + id: `bar:${newBarId}`, + type: 'bar', + bar: { nomnom: 2 }, + references: [{ type: 'foo', id: newFooId, name: 'Foo 1 spacex' }], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { bar: '2.0.0' }, + coreMigrationVersion: KIBANA_VERSION, + }, + { + // new object + id: 'legacy-url-alias:spacex:bar:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newBarId, + targetNamespace: 'spacex', + targetType: 'bar', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: KIBANA_VERSION, + }, + { + id: 'baz:1', + type: 'baz', + baz: { title: 'Baz 1 default' }, + references: [{ type: 'bar', id: '1', name: 'Bar 1 default' }], + coreMigrationVersion: KIBANA_VERSION, + }, + { + id: 'spacex:baz:1', + type: 'baz', + baz: { title: 'Baz 1 spacex' }, + references: [{ type: 'bar', id: newBarId, name: 'Bar 1 spacex' }], + namespace: 'spacex', + coreMigrationVersion: KIBANA_VERSION, + }, + ].sort(sortByTypeAndId) + ); + }); }); }; @@ -340,6 +561,30 @@ async function createIndex({ esClient, index }: { esClient: ElasticsearchClient; foo: { properties: { name: { type: 'keyword' } } }, bar: { properties: { nomnom: { type: 'integer' } } }, baz: { properties: { title: { type: 'keyword' } } }, + 'legacy-url-alias': { + properties: { + targetNamespace: { type: 'text' }, + targetType: { type: 'text' }, + targetId: { type: 'text' }, + lastResolved: { type: 'date' }, + resolveCounter: { type: 'integer' }, + disabled: { type: 'boolean' }, + }, + }, + namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, + coreMigrationVersion: { + type: 'keyword', + }, }; await esClient.indices.create({ index, @@ -369,23 +614,23 @@ async function createDocs({ async function migrateIndex({ esClient, index, - migrations, + savedObjectTypes, mappingProperties, obsoleteIndexTemplatePattern, }: { esClient: ElasticsearchClient; index: string; - migrations: Record; + savedObjectTypes: SavedObjectsType[]; mappingProperties: SavedObjectsTypeMappingDefinitions; obsoleteIndexTemplatePattern?: string; }) { const typeRegistry = new SavedObjectTypeRegistry(); - const types = migrationsToTypes(migrations); - types.forEach((type) => typeRegistry.registerType(type)); + savedObjectTypes.forEach((type) => typeRegistry.registerType(type)); const documentMigrator = new DocumentMigrator({ - kibanaVersion: '99.9.9', + kibanaVersion: KIBANA_VERSION, typeRegistry, + minimumConvertVersion: '0.0.0', // bypass the restriction of a minimum version of 8.0.0 for these integration tests log: getLogMock(), }); @@ -395,6 +640,7 @@ async function migrateIndex({ client: createMigrationEsClient(esClient, getLogMock()), documentMigrator, index, + kibanaVersion: KIBANA_VERSION, obsoleteIndexTemplatePattern, mappingProperties, batchSize: 10, @@ -407,18 +653,6 @@ async function migrateIndex({ return await migrator.migrate(); } -function migrationsToTypes( - migrations: Record -): SavedObjectsType[] { - return Object.entries(migrations).map(([type, migrationsMap]) => ({ - name: type, - hidden: false, - namespaceType: 'single', - mappings: { properties: {} }, - migrations: { ...migrationsMap }, - })); -} - async function fetchDocs(esClient: ElasticsearchClient, index: string) { const { body } = await esClient.search>({ index }); @@ -427,5 +661,9 @@ async function fetchDocs(esClient: ElasticsearchClient, index: string) { ...h._source, id: h._id, })) - .sort((a, b) => a.id.localeCompare(b.id)); + .sort(sortByTypeAndId); +} + +function sortByTypeAndId(a: { type: string; id: string }, b: { type: string; id: string }) { + return a.type.localeCompare(b.type) || a.id.localeCompare(b.id); } diff --git a/test/api_integration/apis/saved_objects/resolve.ts b/test/api_integration/apis/saved_objects/resolve.ts new file mode 100644 index 0000000000000..b71d5e3003495 --- /dev/null +++ b/test/api_integration/apis/saved_objects/resolve.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../ftr_provider_context'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + const esArchiver = getService('esArchiver'); + + describe('resolve', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + + describe('with kibana index', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200', async () => + await supertest + .get(`/api/saved_objects/resolve/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + saved_object: { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: resp.body.saved_object.version, + migrationVersion: resp.body.saved_object.migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + attributes: { + title: 'Count of requests', + description: '', + version: 1, + // cheat for some of the more complex attributes + visState: resp.body.saved_object.attributes.visState, + uiStateJSON: resp.body.saved_object.attributes.uiStateJSON, + kibanaSavedObjectMeta: resp.body.saved_object.attributes.kibanaSavedObjectMeta, + }, + references: [ + { + type: 'index-pattern', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + }, + ], + namespaces: ['default'], + }, + outcome: 'exactMatch', + }); + expect(resp.body.saved_object.migrationVersion).to.be.ok(); + })); + + describe('doc does not exist', () => { + it('should return same generic error as when index does not exist', async () => + await supertest + .get(`/api/saved_objects/resolve/visualization/foobar`) + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Not Found', + message: 'Saved object [visualization/foobar] not found', + statusCode: 404, + }); + })); + }); + }); + + describe('without kibana index', () => { + before( + async () => + // just in case the kibana server has recreated it + await es.indices.delete({ + index: '.kibana', + ignore: [404], + }) + ); + + it('should return basic 404 without mentioning index', async () => + await supertest + .get('/api/saved_objects/resolve/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Not Found', + message: + 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 404, + }); + })); + }); + }); +} diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index d7b486e8ab5cf..acc01c73de674 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -14,8 +14,17 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); describe('find', () => { + let KIBANA_VERSION: string; + + before(async () => { + KIBANA_VERSION = await kibanaServer.version.get(); + expect(typeof KIBANA_VERSION).to.eql('string'); + expect(KIBANA_VERSION.length).to.be.greaterThan(0); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -38,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], references: [ { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 85ec08fb7388d..90700f8fa7521 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -1452,6 +1452,140 @@ describe('#get', () => { }); }); +describe('#resolve', () => { + it('redirects request to underlying base client and does not alter response if type is not registered', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'unknown-type', + attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('unknown-type', 'some-id', options)).resolves.toEqual( + mockedResponse + ); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('unknown-type', 'some-id', options); + }); + + it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + saved_object: { + ...mockedResponse.saved_object, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }, + }); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error with modified outcome if decryption fails.', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + saved_object: { + ...mockedResponse.saved_object, + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }, + }); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.resolve.mockRejectedValue(failureReason); + + await expect(wrapper.resolve('known-type', 'some-id')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', undefined); + }); +}); + describe('#update', () => { it('redirects request to underlying base client if type is not registered', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 313e7c7da9eba..c3008a8e86505 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -181,6 +181,19 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ); } + public async resolve(type: string, id: string, options?: SavedObjectsBaseOptions) { + const resolveResult = await this.options.baseClient.resolve(type, id, options); + const object = await this.handleEncryptedAttributesInResponse( + resolveResult.saved_object, + undefined as unknown, + getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) + ); + return { + ...resolveResult, + saved_object: object, + }; + } + public async update( type: string, id: string, diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts index f7e41bce674ee..ef77170de69e2 100644 --- a/x-pack/plugins/security/server/audit/audit_events.test.ts +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -115,6 +115,12 @@ describe('#savedObjectEvent', () => { savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + }) + ).not.toBeUndefined(); expect( savedObjectEvent({ action: SavedObjectAction.FIND, @@ -136,6 +142,18 @@ describe('#savedObjectEvent', () => { savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, + }) + ).toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, + }) + ).toBeUndefined(); expect( savedObjectEvent({ action: SavedObjectAction.FIND, diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index b6538af31bd60..f7d99877bca27 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -182,6 +182,7 @@ export function userLoginEvent({ export enum SavedObjectAction { CREATE = 'saved_object_create', GET = 'saved_object_get', + RESOLVE = 'saved_object_resolve', UPDATE = 'saved_object_update', DELETE = 'saved_object_delete', FIND = 'saved_object_find', @@ -195,6 +196,7 @@ type VerbsTuple = [string, string, string]; const savedObjectAuditVerbs: Record = { saved_object_create: ['create', 'creating', 'created'], saved_object_get: ['access', 'accessing', 'accessed'], + saved_object_resolve: ['resolve', 'resolving', 'resolved'], saved_object_update: ['update', 'updating', 'updated'], saved_object_delete: ['delete', 'deleting', 'deleted'], saved_object_find: ['access', 'accessing', 'accessed'], @@ -210,6 +212,7 @@ const savedObjectAuditVerbs: Record = { const savedObjectAuditTypes: Record = { saved_object_create: EventType.CREATION, saved_object_get: EventType.ACCESS, + saved_object_resolve: EventType.ACCESS, saved_object_update: EventType.CHANGE, saved_object_delete: EventType.DELETION, saved_object_find: EventType.ACCESS, diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 15ca8bac89bd6..5c421776d54f0 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -175,6 +175,7 @@ const expectObjectNamespaceFiltering = async ( // we don't know which base client method will be called; mock them all clientOpts.baseClient.create.mockReturnValue(returnValue as any); clientOpts.baseClient.get.mockReturnValue(returnValue as any); + // 'resolve' is excluded because it has a specific test case written for it clientOpts.baseClient.update.mockReturnValue(returnValue as any); clientOpts.baseClient.addToNamespaces.mockReturnValue(returnValue as any); clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(returnValue as any); @@ -985,6 +986,82 @@ describe('#get', () => { }); }); +describe('#resolve', () => { + const type = 'foo'; + const id = `${type}-id`; + const namespace = 'some-ns'; + const resolvedId = 'another-id'; // success audit records include the resolved ID, not the requested ID + const mockResult = { saved_object: { id: resolvedId } }; // mock result needs to have ID for audit logging + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.resolve, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; + await expectForbiddenError(client.resolve, { type, id, options }, 'resolve'); + }); + + test(`returns result of baseClient.resolve when authorized`, async () => { + const apiCallReturnValue = mockResult; + clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await expectSuccess(client.resolve, { type, id, options }, 'resolve'); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const options = { namespace }; + await expectPrivilegeCheck(client.resolve, { type, id, options }, namespace); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; + + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const namespaces = ['some-other-namespace', '*', namespace]; + const returnValue = { saved_object: { namespaces, id: resolvedId, foo: 'bar' } }; + clientOpts.baseClient.resolve.mockReturnValue(returnValue as any); + + const result = await client.resolve(type, id, options); + // we will never redact the "All Spaces" ID + expect(result).toEqual({ + saved_object: expect.objectContaining({ namespaces: ['*', namespace, '?'] }), + }); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( + 'login:', + ['some-other-namespace'] + // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs + // we don't check privileges for authorizedNamespace either, as that was already checked earlier in the operation + ); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = mockResult; + clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.resolve, { type, id, options }, 'resolve'); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_resolve', EventOutcome.SUCCESS, { type, id: resolvedId }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.resolve(type, id, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_resolve', EventOutcome.FAILURE, { type, id }); + }); +}); + describe('#deleteFromNamespaces', () => { const type = 'foo'; const id = `${type}-id`; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 765274a839efa..e53bb742e2179 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -335,6 +335,42 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } + public async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ) { + try { + const args = { type, id, options }; + await this.ensureAuthorized(type, 'get', options.namespace, { args, auditAction: 'resolve' }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + + const resolveResult = await this.baseClient.resolve(type, id, options); + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id: resolveResult.saved_object.id }, + }) + ); + + return { + ...resolveResult, + saved_object: await this.redactSavedObjectNamespaces(resolveResult.saved_object, [ + options.namespace, + ]), + }; + } + public async update( type: string, id: string, diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 4fd9529507335..a79651c1ae9a6 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -103,6 +103,37 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#resolve', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.resolve('foo', '', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { + saved_object: createMockResponse(), + outcome: 'exactMatch' as 'exactMatch', // outcome doesn't matter, just including it for type safety + }; + baseClient.resolve.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.resolve(type, id, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.resolve).toHaveBeenCalledWith(type, id, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#bulkGet', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = createSpacesSavedObjectsClient(); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 049bd88085ed5..bd09b8237a468 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -246,6 +246,28 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } + /** + * Resolves a single object, using any legacy URL alias if it exists + * + * @param type - The type of SavedObject to retrieve + * @param id - The ID of the SavedObject to retrieve + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { saved_object, outcome } + */ + public async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.resolve(type, id, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Updates an object * diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index d9d5c6f9c5808..32cae675dea74 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -502,3 +502,119 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:exact-match", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome exactMatch" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:alias-match-newid", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome aliasMatch" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:resolvetype:alias-match", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "resolvetype", + "targetId": "alias-match-newid" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:resolvetype:disabled", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "resolvetype", + "targetId": "alias-match-newid", + "disabled": true + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:conflict", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome conflict (1 of 2)" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:conflict-newid", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome conflict (2 of 2)" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:resolvetype:conflict", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "resolvetype", + "targetId": "conflict-newid" + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 73f0e536b9295..561c2ecc56fa2 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -176,6 +176,28 @@ } } }, + "legacy-url-alias": { + "properties": { + "targetNamespace": { + "type": "keyword" + }, + "targetType": { + "type": "keyword" + }, + "targetId": { + "type": "keyword" + }, + "lastResolved": { + "type": "date" + }, + "resolveCounter": { + "type": "integer" + }, + "disabled": { + "type": "boolean" + } + } + }, "namespace": { "type": "keyword" }, @@ -185,6 +207,13 @@ "originId": { "type": "keyword" }, + "resolvetype": { + "properties": { + "title": { + "type": "text" + } + } + }, "search": { "properties": { "columns": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index 45880635586a7..d311e539b1687 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -64,6 +64,13 @@ export class Plugin { namespaceType: 'single', mappings, }); + core.savedObjects.registerType({ + name: 'resolvetype', + hidden: false, + namespaceType: 'multiple', + management, + mappings, + }); } public start() { diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts new file mode 100644 index 0000000000000..250a3b19710a9 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { SuperTest } from 'supertest'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +export interface ResolveTestDefinition extends TestDefinition { + request: { type: string; id: string }; +} +export type ResolveTestSuite = TestSuite; +export interface ResolveTestCase extends TestCase { + expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; + expectedId?: string; +} + +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + +export const TEST_CASES = Object.freeze({ + EXACT_MATCH: Object.freeze({ + type: 'resolvetype', + id: 'exact-match', + expectedNamespaces: EACH_SPACE, + expectedOutcome: 'exactMatch' as 'exactMatch', + expectedId: 'exact-match', + }), + ALIAS_MATCH: Object.freeze({ + type: 'resolvetype', + id: 'alias-match', + expectedNamespaces: EACH_SPACE, + expectedOutcome: 'aliasMatch' as 'aliasMatch', + expectedId: 'alias-match-newid', + }), + CONFLICT: Object.freeze({ + type: 'resolvetype', + id: 'conflict', + expectedNamespaces: EACH_SPACE, + expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists + expectedId: 'conflict', + }), + DISABLED: Object.freeze({ + type: 'resolvetype', + id: 'disabled', + }), + DOES_NOT_EXIST: Object.freeze({ + type: 'resolvetype', + id: 'does-not-exist', + }), + HIDDEN: CASES.HIDDEN, +}); + +export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('get'); + const expectResponseBody = (testCase: ResolveTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectSavedObjectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body.saved_object || response.body; // errors do not have a saved_object field + const { expectedId: id, expectedOutcome } = testCase; + await expectResponses.permitted(object, { ...testCase, ...(id && { id }) }); + if (expectedOutcome && !testCase.failure) { + expect(response.body.outcome).to.eql(expectedOutcome); + } + } + }; + const createTestDefinitions = ( + testCases: ResolveTestCase | ResolveTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): ResolveTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map((x) => ({ ...x, failure: 403 })); + } + return cases.map((x) => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); + }; + + const makeResolveTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: ResolveTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/resolve/${type}/${id}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeResolveTest(describe); + // @ts-ignore + addTests.only = makeResolveTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index 3cc6b85cb97c0..5e9e499ffea18 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -28,6 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts new file mode 100644 index 0000000000000..94df364c9017c --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + resolveTestSuiteFactory, + TEST_CASES as CASES, + ResolveTestDefinition, +} from '../../common/suites/resolve'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.EXACT_MATCH, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { + ...CASES.CONFLICT, + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }), + }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = resolveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; + + describe('_resolve', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ResolveTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorized); + }); + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts index c52ba3f595711..46b0992480764 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -28,6 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts new file mode 100644 index 0000000000000..9f37f97881071 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + resolveTestSuiteFactory, + TEST_CASES as CASES, + ResolveTestDefinition, +} from '../../common/suites/resolve'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.EXACT_MATCH }, + { ...CASES.ALIAS_MATCH, ...fail404() }, + { ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as 'exactMatch' }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = resolveTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; + + describe('_resolve', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: ResolveTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index c8050733fc6e9..137596bc20c4c 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -20,6 +20,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts new file mode 100644 index 0000000000000..a6f76fc80044d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { resolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/resolve'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + CASES.EXACT_MATCH, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { + ...CASES.CONFLICT, + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }), + }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = resolveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; + + describe('_resolve', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); + }); + }); +}