[8.10] [SOR] Allow optionally downgrading documents with a higher version model in API READ methods (#164789) (#164924)

# Backport

This will backport the following commits from `main` to `8.10`:
- [[SOR] Allow optionally downgrading documents with a higher version
model in API READ methods
(#164789)](https://github.com/elastic/kibana/pull/164789)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Christiane (Tina)
Heiligers","email":"christiane.heiligers@elastic.co"},"sourceCommit":{"committedDate":"2023-08-26T20:32:40Z","message":"[SOR]
Allow optionally downgrading documents with a higher version model in
API READ methods (#164789)\n\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"0f4052bc200b5e2d29e2b10983335fa5c13510fe","branchLabelMapping":{"^v8.11.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Saved
Objects","release_note:skip","backport:all-open","Epic:KBNA-7996","v8.10.0","v8.11.0"],"number":164789,"url":"https://github.com/elastic/kibana/pull/164789","mergeCommit":{"message":"[SOR]
Allow optionally downgrading documents with a higher version model in
API READ methods (#164789)\n\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"0f4052bc200b5e2d29e2b10983335fa5c13510fe"}},"sourceBranch":"main","suggestedTargetBranches":["8.10"],"targetPullRequestStates":[{"branch":"8.10","label":"v8.10.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.11.0","labelRegex":"^v8.11.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/164789","number":164789,"mergeCommit":{"message":"[SOR]
Allow optionally downgrading documents with a higher version model in
API READ methods (#164789)\n\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"0f4052bc200b5e2d29e2b10983335fa5c13510fe"}}]}]
BACKPORT-->

Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
This commit is contained in:
Kibana Machine 2023-08-26 17:42:02 -04:00 committed by GitHub
parent 83b33f2c40
commit c24835fb12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 141 additions and 52 deletions

View file

@ -25,6 +25,7 @@ export type SavedObjectsFindOptions = Omit<
| 'sortOrder'
| 'typeToNamespacesMap'
| 'migrationVersionCompatibility'
| 'downwardConversion'
>;
/**

View file

@ -65,7 +65,7 @@ export const performBulkGet = async <T>(
const { securityExtension, spacesExtension } = extensions;
const namespace = commonHelper.getCurrentNamespace(options.namespace);
const { migrationVersionCompatibility } = options;
const { migrationVersionCompatibility, downwardConversion } = options;
if (objects.length === 0) {
return { saved_objects: [] };
@ -204,7 +204,7 @@ export const performBulkGet = async <T>(
const document = getSavedObjectFromSource(registry, type, id, doc, {
migrationVersionCompatibility,
});
const migrated = migrationHelper.migrateStorageDocument(document);
const migrated = migrationHelper.migrateStorageDocument(document, { downwardConversion });
return migrated;
}),

View file

@ -10,7 +10,7 @@ import { type SavedObject, BulkResolveError } from '@kbn/core-saved-objects-serv
import {
SavedObjectsBulkResolveObject,
SavedObjectsBulkResolveResponse,
SavedObjectsResolveOptions,
SavedObjectsGetOptions,
SavedObjectsResolveResponse,
} from '@kbn/core-saved-objects-api-server';
import { errorContent } from './utils';
@ -20,7 +20,7 @@ import { incrementCounterInternal } from './internals/increment_counter_internal
export interface PerformCreateParams<T = unknown> {
objects: SavedObjectsBulkResolveObject[];
options: SavedObjectsResolveOptions;
options: SavedObjectsGetOptions;
}
export const performBulkResolve = async <T>(
@ -52,7 +52,7 @@ export const performBulkResolve = async <T>(
encryptionExtension,
securityExtension,
objects,
options: { ...options, namespace },
options: { ...options, namespace }, // note: Includes downwardConversion?: 'forbid'
});
const resolvedObjects = bulkResults.map<SavedObjectsResolveResponse<T>>((result) => {
// extract payloads from saved object errors

View file

@ -92,6 +92,7 @@ export const performFind = async <T = unknown, A = unknown>(
preference,
aggs,
migrationVersionCompatibility,
downwardConversion,
} = options;
if (!type) {
@ -244,7 +245,9 @@ export const performFind = async <T = unknown, A = unknown>(
});
// can't migrate a document with partial attributes
if (!fields) {
savedObject = migrationHelper.migrateStorageDocument(savedObject) as SavedObject;
savedObject = migrationHelper.migrateStorageDocument(savedObject, {
downwardConversion,
}) as SavedObject;
}
return {
...savedObject,

View file

@ -42,7 +42,7 @@ export const performGet = async <T>(
const { securityExtension } = extensions;
const namespace = commonHelper.getCurrentNamespace(options.namespace);
const { migrationVersionCompatibility } = options;
const { migrationVersionCompatibility, downwardConversion } = options;
if (!allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
@ -86,7 +86,9 @@ export const performGet = async <T>(
let migrated: SavedObject<T>;
try {
migrated = migrationHelper.migrateStorageDocument(document) as SavedObject<T>;
migrated = migrationHelper.migrateStorageDocument(document, {
downwardConversion,
}) as SavedObject<T>;
} catch (error) {
throw SavedObjectsErrorHelpers.decorateGeneralError(
error,

View file

@ -35,8 +35,17 @@ export class MigrationHelper {
* Migrate the given SO document, accepting downgrades.
* This function is meant to be used by read APIs (get, find) for documents fetched from the index.
* It will therefore accept downgrading the document before returning it from the API.
*
* Note: to opt out of downgrades, use the downwardConversion: 'forbid' API option in READ API operations:
* get, resolve, find, bulk_get, bulk_resolve
*/
migrateStorageDocument(document: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc {
return this.migrator.migrateDocument(document, { allowDowngrade: true });
migrateStorageDocument(
document: SavedObjectUnsanitizedDoc,
options: { downwardConversion?: 'allow' | 'forbid' }
): SavedObjectUnsanitizedDoc {
return this.migrator.migrateDocument(document, {
allowDowngrade:
options?.downwardConversion && options.downwardConversion === 'forbid' ? false : true,
}); // allowDowngrade conditional on downwardConversion
}
}

View file

@ -11,7 +11,7 @@ import type { MgetResponseItem } from '@elastic/elasticsearch/lib/api/typesWithB
import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal';
import type {
SavedObjectsBulkResolveObject,
SavedObjectsResolveOptions,
SavedObjectsGetOptions,
SavedObjectsResolveResponse,
SavedObjectsIncrementCounterField,
SavedObjectsIncrementCounterOptions,
@ -74,7 +74,7 @@ export interface InternalBulkResolveParams {
encryptionExtension: ISavedObjectsEncryptionExtension | undefined;
securityExtension: ISavedObjectsSecurityExtension | undefined;
objects: SavedObjectsBulkResolveObject[];
options?: SavedObjectsResolveOptions;
options?: SavedObjectsGetOptions;
}
/**
@ -120,7 +120,7 @@ export async function internalBulkResolve<T>(
const validObjects = allObjects.filter(isRight);
const namespace = normalizeNamespace(options.namespace);
const { migrationVersionCompatibility } = options;
const { migrationVersionCompatibility, downwardConversion } = options;
const aliasDocs = await fetchAndUpdateAliases(
validObjects,
@ -186,8 +186,11 @@ export async function internalBulkResolve<T>(
// @ts-expect-error MultiGetHit._source is optional
const object = getSavedObjectFromSource<T>(registry, objectType, objectId, doc, {
migrationVersionCompatibility,
downwardConversion,
});
const migrated = migrator.migrateDocument(object, { allowDowngrade: true }) as SavedObject<T>;
const migrated = migrator.migrateDocument(object, {
allowDowngrade: downwardConversion && downwardConversion === 'forbid' ? false : true, // 'forbid' => docMigrator throws on when documents have higher model versions than current.
}) as SavedObject<T>;
if (!encryptionExtension?.isEncryptableType(migrated.type)) {
return migrated;

View file

@ -7,7 +7,7 @@
*/
import {
SavedObjectsResolveOptions,
SavedObjectsGetOptions,
SavedObjectsResolveResponse,
} from '@kbn/core-saved-objects-api-server';
import { ApiExecutionContext } from './types';
@ -17,7 +17,7 @@ import { incrementCounterInternal } from './internals/increment_counter_internal
export interface PerformCreateParams<T = unknown> {
type: string;
id: string;
options: SavedObjectsResolveOptions;
options: SavedObjectsGetOptions;
}
export const performResolve = async <T>(
@ -51,7 +51,7 @@ export const performResolve = async <T>(
encryptionExtension,
securityExtension,
objects: [{ type, id }],
options: { ...options, namespace },
options: { ...options, namespace }, // note: Includes downwardConversion?: 'forbid'
});
const [result] = bulkResults;
if (isBulkResolveError(result)) {

View file

@ -35,7 +35,6 @@ import type {
SavedObjectsClosePointInTimeResponse,
ISavedObjectsPointInTimeFinder,
SavedObjectsCreatePointInTimeFinderDependencies,
SavedObjectsResolveOptions,
SavedObjectsResolveResponse,
SavedObjectsCollectMultiNamespaceReferencesObject,
SavedObjectsUpdateObjectsSpacesObject,
@ -348,7 +347,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
*/
async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options: SavedObjectsResolveOptions = {}
options: SavedObjectsGetOptions = {}
): Promise<SavedObjectsBulkResolveResponse<T>> {
return await performBulkResolve(
{
@ -383,7 +382,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
async resolve<T = unknown>(
type: string,
id: string,
options: SavedObjectsResolveOptions = {}
options: SavedObjectsGetOptions = {}
): Promise<SavedObjectsResolveResponse<T>> {
return await performResolve(
{

View file

@ -31,7 +31,6 @@ import type {
SavedObjectsBulkUpdateObject,
ISavedObjectsPointInTimeFinder,
SavedObjectsCreatePointInTimeFinderDependencies,
SavedObjectsResolveOptions,
SavedObjectsResolveResponse,
SavedObjectsCollectMultiNamespaceReferencesObject,
SavedObjectsUpdateObjectsSpacesObject,
@ -122,7 +121,7 @@ export class SavedObjectsClient implements SavedObjectsClientContract {
/** {@inheritDoc SavedObjectsClientContract.bulkResolve} */
async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options?: SavedObjectsResolveOptions
options?: SavedObjectsGetOptions
): Promise<SavedObjectsBulkResolveResponse<T>> {
return await this._repository.bulkResolve(objects, options);
}
@ -131,7 +130,7 @@ export class SavedObjectsClient implements SavedObjectsClientContract {
async resolve<T = unknown>(
type: string,
id: string,
options: SavedObjectsResolveOptions = {}
options: SavedObjectsGetOptions = {}
): Promise<SavedObjectsResolveResponse<T>> {
return await this._repository.resolve(type, id, options);
}

View file

@ -44,7 +44,6 @@ export type {
ISavedObjectsPointInTimeFinder,
SavedObjectsCreatePointInTimeFinderDependencies,
SavedObjectsPitParams,
SavedObjectsResolveOptions,
SavedObjectsResolveResponse,
SavedObjectsCollectMultiNamespaceReferencesObject,
SavedObjectsUpdateObjectsSpacesResponseObject,

View file

@ -11,7 +11,7 @@ import type {
AggregationsAggregationContainer,
SortResults,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { SavedObject } from '../..';
import type { SavedObject, SavedObjectsGetOptions } from '../..';
type KueryNode = any;
@ -153,7 +153,8 @@ export interface SavedObjectsFindOptions {
*/
pit?: SavedObjectsPitParams;
/** {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility} */
migrationVersionCompatibility?: 'compatible' | 'raw';
migrationVersionCompatibility?: SavedObjectsGetOptions['migrationVersionCompatibility'];
downwardConversion?: SavedObjectsGetOptions['downwardConversion'];
}
/**

View file

@ -16,4 +16,5 @@ import { SavedObjectsBaseOptions } from './base';
export interface SavedObjectsGetOptions extends SavedObjectsBaseOptions {
/** {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility} */
migrationVersionCompatibility?: 'compatible' | 'raw';
downwardConversion?: 'allow' | 'forbid';
}

View file

@ -65,7 +65,7 @@ export type {
SavedObjectsRemoveReferencesToOptions,
SavedObjectsRemoveReferencesToResponse,
} from './remove_references_to';
export type { SavedObjectsResolveOptions, SavedObjectsResolveResponse } from './resolve';
export type { SavedObjectsResolveResponse } from './resolve';
export type { SavedObjectsUpdateResponse, SavedObjectsUpdateOptions } from './update';
export type {
SavedObjectsUpdateObjectsSpacesObject,

View file

@ -6,19 +6,8 @@
* Side Public License, v 1.
*/
import { SavedObjectsBaseOptions } from './base';
import type { SavedObject } from '../..';
/**
* Options for the saved objects get operation
*
* @public
*/
export interface SavedObjectsResolveOptions extends SavedObjectsBaseOptions {
/** {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility} */
migrationVersionCompatibility?: 'compatible' | 'raw';
}
/**
*
* @public

View file

@ -20,7 +20,6 @@ import type {
SavedObjectsUpdateObjectsSpacesOptions,
SavedObjectsCollectMultiNamespaceReferencesObject,
SavedObjectsUpdateObjectsSpacesResponse,
SavedObjectsResolveOptions,
SavedObjectsResolveResponse,
ISavedObjectsPointInTimeFinder,
SavedObjectsRemoveReferencesToOptions,
@ -217,7 +216,7 @@ export interface SavedObjectsClientContract {
* See documentation for `.resolve`.
*
* @param objects - an array of objects to resolve (contains id and type)
* @param options {@link SavedObjectsResolveOptions} - options for the bulk resolve operation
* @param options {@link SavedObjectsGetOptions} - options for the bulk resolve operation
* @returns the {@link SavedObjectsBulkResolveResponse}
* @example
*
@ -232,7 +231,7 @@ export interface SavedObjectsClientContract {
*/
bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options?: SavedObjectsResolveOptions
options?: SavedObjectsGetOptions
): Promise<SavedObjectsBulkResolveResponse<T>>;
/**
@ -248,13 +247,13 @@ export interface SavedObjectsClientContract {
*
* @param type - The type of SavedObject to retrieve
* @param id - The ID of the SavedObject to retrieve
* @param options {@link SavedObjectsResolveOptions} - options for the resolve operation
* @param options {@link SavedObjectsGetOptions} - options for the resolve operation
* @returns the {@link SavedObjectsResolveResponse}
*/
resolve<T = unknown>(
type: string,
id: string,
options?: SavedObjectsResolveOptions
options?: SavedObjectsGetOptions
): Promise<SavedObjectsResolveResponse<T>>;
/**

View file

@ -20,7 +20,6 @@ import type {
SavedObjectsUpdateObjectsSpacesOptions,
SavedObjectsCollectMultiNamespaceReferencesObject,
SavedObjectsUpdateObjectsSpacesResponse,
SavedObjectsResolveOptions,
SavedObjectsResolveResponse,
ISavedObjectsPointInTimeFinder,
SavedObjectsRemoveReferencesToOptions,
@ -201,7 +200,7 @@ export interface ISavedObjectsRepository {
* Resolves an array of objects by id, using any legacy URL aliases if they exist
*
* @param {array} objects - an array of objects containing id, type
* @param {object} [options={}] {@link SavedObjectsResolveOptions} - options for the bulk resolve operation
* @param {object} [options={}] {@link SavedObjectsGetOptions} - options for the bulk resolve operation
* @property {string} [options.migrationVersionCompatibility]
* @property {string} [options.namespace]
* @returns {promise} - { resolved_objects: [{ saved_object, outcome }] }
@ -214,7 +213,7 @@ export interface ISavedObjectsRepository {
*/
bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options?: SavedObjectsResolveOptions
options?: SavedObjectsGetOptions
): Promise<SavedObjectsBulkResolveResponse<T>>;
/**
@ -238,7 +237,7 @@ export interface ISavedObjectsRepository {
*
* @param {string} type - the type of the object to resolve
* @param {string} id - the id of the object to resolve
* @param {object} [options={}] {@link SavedObjectsResolveOptions} - options for the resolve operation
* @param {object} [options={}] {@link SavedObjectsGetOptions} - options for the resolve operation
* @property {string} [options.migrationVersionCompatibility]
* @property {string} [options.namespace]
* @returns {promise} - { saved_object, outcome }
@ -246,7 +245,7 @@ export interface ISavedObjectsRepository {
resolve<T = unknown>(
type: string,
id: string,
options?: SavedObjectsResolveOptions
options?: SavedObjectsGetOptions
): Promise<SavedObjectsResolveResponse<T>>;
/**

View file

@ -1462,7 +1462,7 @@ describe('DocumentMigrator', () => {
expect(() =>
migrator.migrate(document, { allowDowngrade: false })
).toThrowErrorMatchingInlineSnapshot(
`"Document \\"smelly\\" belongs to a more recent version of Kibana [10.2.0] when the last known version is [10.1.0]."`
`"[NewerModelVersionError]: Document \\"smelly\\" belongs to a more recent version of Kibana [10.2.0] when the last known version is [10.1.0]."`
);
});
});

View file

@ -81,6 +81,9 @@ export interface VersionedTransformer {
migrateAndConvert(doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc[];
}
export function createNewerModelVersionError(message: string) {
return Boom.boomify(Boom.badData(message), { message: '[NewerModelVersionError]' });
}
/**
* A concrete implementation of the {@link VersionedTransformer} interface.
*/
@ -181,9 +184,11 @@ export class DocumentMigrator implements VersionedTransformer {
const currentVersion = doc.typeMigrationVersion ?? doc.migrationVersion?.[doc.type];
const latestVersion = this.migrations[doc.type].latestVersion[TransformType.Migrate];
if (!allowDowngrade) {
throw Boom.badData(
`Document "${doc.id}" belongs to a more recent version of Kibana [${currentVersion}] when the last known version is [${latestVersion}].`
);
if (!allowDowngrade) {
throw createNewerModelVersionError(
`Document "${doc.id}" belongs to a more recent version of Kibana [${currentVersion}] when the last known version is [${latestVersion}].`
);
}
}
return this.transformDown(doc, { targetTypeVersion: latestVersion! });
} else {

View file

@ -138,6 +138,26 @@ describe('Higher version doc conversion', () => {
newField: 'someValue',
});
});
it('throws error for documents using higher version model than current', async () => {
try {
await repositoryV1.get('test-type', 'doc-1', {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err.message).toBe(
'[NewerModelVersionError]: Document "doc-1" belongs to a more recent version of Kibana [10.2.0] when the last known version is [10.1.0].'
);
}
});
it("doesn't throw error for documents using current version model when 'downwardConversion' is 'forbid'", async () => {
try {
await repositoryV2.get('test-type', 'doc-1', {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err).toBeUndefined();
}
});
});
describe('#bulkGet', () => {
@ -155,6 +175,26 @@ describe('Higher version doc conversion', () => {
newField: 'someValue',
});
});
it('throws error for documents using higher version model than current', async () => {
try {
await repositoryV2.bulkGet([{ type: 'test-type', id: 'doc-1' }], {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err.message).toBe(
'[NewerModelVersionError]: Document "doc-1" belongs to a more recent version of Kibana [10.2.0] when the last known version is [10.1.0].'
);
}
});
it("doesn't throw error for documents using current version model when 'downwardConversion' is 'forbid'", async () => {
try {
await repositoryV2.get('test-type', 'doc-1', {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err).toBeUndefined();
}
});
});
describe('#resolve', () => {
@ -172,6 +212,26 @@ describe('Higher version doc conversion', () => {
newField: 'someValue',
});
});
it('throws error for documents using higher version model than current', async () => {
try {
await repositoryV2.resolve('test-type', 'doc-1', {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err.message).toBe(
'[NewerModelVersionError]: Document "doc-1" belongs to a more recent version of Kibana [10.2.0] when the last known version is [10.1.0].'
);
}
});
it("doesn't throw error for documents using current version model when 'downwardConversion' is 'forbid'", async () => {
try {
await repositoryV2.get('test-type', 'doc-1', {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err).toBeUndefined();
}
});
});
describe('#bulkResolve', () => {
@ -189,5 +249,25 @@ describe('Higher version doc conversion', () => {
newField: 'someValue',
});
});
it('throws error for documents using higher version model than current', async () => {
try {
await repositoryV2.bulkResolve([{ type: 'test-type', id: 'doc-1' }], {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err.message).toBe(
'[NewerModelVersionError]: Document "doc-1" belongs to a more recent version of Kibana [10.2.0] when the last known version is [10.1.0].'
);
}
});
it("doesn't throw error for documents using current version model when 'downwardConversion' is 'forbid'", async () => {
try {
await repositoryV2.get('test-type', 'doc-1', {
downwardConversion: 'forbid',
});
} catch (err) {
expect(err).toBeUndefined();
}
});
});
});