Adds validations for Saved Object types when calling create or bulkCreate. (#118969)

This commit is contained in:
Luke Elmers 2022-01-04 22:40:42 -07:00 committed by GitHub
parent f7934116c7
commit ca05637afa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 810 additions and 27 deletions

View file

@ -217,6 +217,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) | Details about a specific object's update result. |
| [SavedObjectsUpdateOptions](./kibana-plugin-core-server.savedobjectsupdateoptions.md) | |
| [SavedObjectsUpdateResponse](./kibana-plugin-core-server.savedobjectsupdateresponse.md) | |
| [SavedObjectsValidationMap](./kibana-plugin-core-server.savedobjectsvalidationmap.md) | A map of [validation specs](./kibana-plugin-core-server.savedobjectsvalidationspec.md) to be used for a given type. The map's keys must be valid semver versions.<!-- -->Any time you change the schema of a [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md)<!-- -->, you should add a new entry to this map for the Kibana version the change was introduced in. |
| [SearchResponse](./kibana-plugin-core-server.searchresponse.md) | |
| [ServiceStatus](./kibana-plugin-core-server.servicestatus.md) | The current status of a service at a point in time. |
| [SessionCookieValidationResult](./kibana-plugin-core-server.sessioncookievalidationresult.md) | Return type from a function to validate cookie contents. |
@ -320,6 +321,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.<!-- -->Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. |
| [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.<!-- -->See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. |
| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. |
| [SavedObjectsValidationSpec](./kibana-plugin-core-server.savedobjectsvalidationspec.md) | Allows for validating properties using @<!-- -->kbn/config-schema validations. |
| [SavedObjectTypeExcludeFromUpgradeFilterHook](./kibana-plugin-core-server.savedobjecttypeexcludefromupgradefilterhook.md) | If defined, allows a type to run a search query and return a query filter that may match any documents which may be excluded from the next migration upgrade process. Useful for cleaning up large numbers of old documents which are no longer needed and may slow the migration process.<!-- -->If this hook fails, the migration will proceed without these documents having been filtered out, so this should not be used as a guarantee that these documents have been deleted.<!-- -->Experimental and subject to change |
| [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana &lt; 7.0.0 which don't have a <code>references</code> root property defined. This type should only be used in migrations. |
| [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.<!-- -->See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md)<!-- -->. |

View file

@ -51,6 +51,6 @@ export class Plugin() {
| --- | --- | --- |
| [addClientWrapper](./kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | (priority: number, id: string, factory: SavedObjectsClientWrapperFactory) =&gt; void | Add a [client wrapper factory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) with the given priority. |
| [getKibanaIndex](./kibana-plugin-core-server.savedobjectsservicesetup.getkibanaindex.md) | () =&gt; string | Returns the default index used for saved objects. |
| [registerType](./kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | &lt;Attributes = any&gt;(type: SavedObjectsType&lt;Attributes&gt;) =&gt; void | Register a [savedObjects type](./kibana-plugin-core-server.savedobjectstype.md) definition.<!-- -->See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) and [migration format](./kibana-plugin-core-server.savedobjectmigrationmap.md) for more details about these. |
| [registerType](./kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | &lt;Attributes extends SavedObjectAttributes = any&gt;(type: SavedObjectsType&lt;Attributes&gt;) =&gt; void | Register a [savedObjects type](./kibana-plugin-core-server.savedobjectstype.md) definition.<!-- -->See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) and [migration format](./kibana-plugin-core-server.savedobjectmigrationmap.md) for more details about these. |
| [setClientFactoryProvider](./kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | (clientFactoryProvider: SavedObjectsClientFactoryProvider) =&gt; void | Set the default [factory provider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) for creating Saved Objects clients. Only one provider can be set, subsequent calls to this method will fail. |

View file

@ -11,7 +11,7 @@ See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdef
<b>Signature:</b>
```typescript
registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
registerType: <Attributes extends SavedObjectAttributes = any>(type: SavedObjectsType<Attributes>) => void;
```
## Example
@ -21,6 +21,7 @@ registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
// src/plugins/my_plugin/server/saved_objects/my_type.ts
import { SavedObjectsType } from 'src/core/server';
import * as migrations from './migrations';
import * as schemas from './schemas';
export const myType: SavedObjectsType = {
name: 'MyType',
@ -40,6 +41,10 @@ export const myType: SavedObjectsType = {
'2.0.0': migrations.migrateToV2,
'2.1.0': migrations.migrateToV2_1
},
schemas: {
'2.0.0': schemas.v2,
'2.1.0': schemas.v2_1,
},
};
// src/plugins/my_plugin/server/plugin.ts

View file

@ -4,16 +4,13 @@
## SavedObjectsType interface
<b>Signature:</b>
```typescript
export interface SavedObjectsType<Attributes = any>
```
## Remarks
This is only internal for now, and will only be public when we expose the registerType API
## Properties
| Property | Type | Description |
@ -57,4 +54,5 @@ Note: migration function(s) can be optionally specified for any of these version
| [migrations?](./kibana-plugin-core-server.savedobjectstype.migrations.md) | SavedObjectMigrationMap \| (() =&gt; SavedObjectMigrationMap) | <i>(Optional)</i> An optional map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) or a function returning a map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used to migrate the type. |
| [name](./kibana-plugin-core-server.savedobjectstype.name.md) | string | The name of the type, which is also used as the internal id. |
| [namespaceType](./kibana-plugin-core-server.savedobjectstype.namespacetype.md) | SavedObjectsNamespaceType | The [namespace type](./kibana-plugin-core-server.savedobjectsnamespacetype.md) for the type. |
| [schemas?](./kibana-plugin-core-server.savedobjectstype.schemas.md) | SavedObjectsValidationMap \| (() =&gt; SavedObjectsValidationMap) | <i>(Optional)</i> An optional schema that can be used to validate the attributes of the type.<!-- -->When provided, calls to [create](./kibana-plugin-core-server.savedobjectsclient.create.md) will be validated against this schema.<!-- -->See [SavedObjectsValidationMap](./kibana-plugin-core-server.savedobjectsvalidationmap.md) for more details. |

View file

@ -0,0 +1,17 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) &gt; [schemas](./kibana-plugin-core-server.savedobjectstype.schemas.md)
## SavedObjectsType.schemas property
An optional schema that can be used to validate the attributes of the type.
When provided, calls to [create](./kibana-plugin-core-server.savedobjectsclient.create.md) will be validated against this schema.
See [SavedObjectsValidationMap](./kibana-plugin-core-server.savedobjectsvalidationmap.md) for more details.
<b>Signature:</b>
```typescript
schemas?: SavedObjectsValidationMap | (() => SavedObjectsValidationMap);
```

View file

@ -0,0 +1,37 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsValidationMap](./kibana-plugin-core-server.savedobjectsvalidationmap.md)
## SavedObjectsValidationMap interface
A map of [validation specs](./kibana-plugin-core-server.savedobjectsvalidationspec.md) to be used for a given type. The map's keys must be valid semver versions.
Any time you change the schema of a [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md)<!-- -->, you should add a new entry to this map for the Kibana version the change was introduced in.
<b>Signature:</b>
```typescript
export interface SavedObjectsValidationMap
```
## Example
```typescript
const validationMap: SavedObjectsValidationMap = {
'1.0.0': schema.object({
foo: schema.string(),
}),
'2.0.0': schema.object({
foo: schema.string({
minLength: 2,
validate(value) {
if (!/^[a-z]+$/.test(value)) {
return 'must be lowercase letters only';
}
}
}),
}),
}
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsValidationSpec](./kibana-plugin-core-server.savedobjectsvalidationspec.md)
## SavedObjectsValidationSpec type
Allows for validating properties using @<!-- -->kbn/config-schema validations.
<b>Signature:</b>
```typescript
export declare type SavedObjectsValidationSpec = ObjectType;
```

View file

@ -363,6 +363,8 @@ export type {
SavedObjectsImportSimpleWarning,
SavedObjectsImportActionRequiredWarning,
SavedObjectsImportWarning,
SavedObjectsValidationMap,
SavedObjectsValidationSpec,
} from './saved_objects';
export type {

View file

@ -93,6 +93,8 @@ export type {
SavedObjectTypeExcludeFromUpgradeFilterHook,
} from './types';
export type { SavedObjectsValidationMap, SavedObjectsValidationSpec } from './validation';
export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config';
export { SavedObjectTypeRegistry } from './saved_objects_type_registry';
export type { ISavedObjectTypeRegistry } from './saved_objects_type_registry';

View file

@ -29,7 +29,12 @@ import {
SavedObjectConfig,
} from './saved_objects_config';
import { KibanaRequest, InternalHttpServiceSetup } from '../http';
import { SavedObjectsClientContract, SavedObjectsType, SavedObjectStatusMeta } from './types';
import {
SavedObjectsClientContract,
SavedObjectsType,
SavedObjectStatusMeta,
SavedObjectAttributes,
} from './types';
import { ISavedObjectsRepository, SavedObjectsRepository } from './service/lib/repository';
import {
SavedObjectsClientFactoryProvider,
@ -112,6 +117,7 @@ export interface SavedObjectsServiceSetup {
* // src/plugins/my_plugin/server/saved_objects/my_type.ts
* import { SavedObjectsType } from 'src/core/server';
* import * as migrations from './migrations';
* import * as schemas from './schemas';
*
* export const myType: SavedObjectsType = {
* name: 'MyType',
@ -131,6 +137,10 @@ export interface SavedObjectsServiceSetup {
* '2.0.0': migrations.migrateToV2,
* '2.1.0': migrations.migrateToV2_1
* },
* schemas: {
* '2.0.0': schemas.v2,
* '2.1.0': schemas.v2_1,
* },
* };
*
* // src/plugins/my_plugin/server/plugin.ts
@ -144,7 +154,9 @@ export interface SavedObjectsServiceSetup {
* }
* ```
*/
registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
registerType: <Attributes extends SavedObjectAttributes = any>(
type: SavedObjectsType<Attributes>
) => void;
/**
* Returns the default index used for saved objects.

View file

@ -22,6 +22,7 @@ import {
import type { Payload } from '@hapi/boom';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { schema } from '@kbn/config-schema';
import {
SavedObjectsType,
SavedObject,
@ -225,7 +226,16 @@ describe('SavedObjectsRepository', () => {
const registry = new SavedObjectTypeRegistry();
registry.registerType(createType('config'));
registry.registerType(createType('index-pattern'));
registry.registerType(createType('dashboard'));
registry.registerType(
createType('dashboard', {
schemas: {
'8.0.0-testing': schema.object({
title: schema.maybe(schema.string()),
otherField: schema.maybe(schema.string()),
}),
},
})
);
registry.registerType(createType(CUSTOM_INDEX_TYPE, { indexPattern: 'custom' }));
registry.registerType(createType(NAMESPACE_AGNOSTIC_TYPE, { namespaceType: 'agnostic' }));
registry.registerType(createType(MULTI_NAMESPACE_TYPE, { namespaceType: 'multiple' }));
@ -971,6 +981,30 @@ describe('SavedObjectsRepository', () => {
};
await bulkCreateError(obj3, true, expectedErrorResult);
});
it(`returns errors for any bulk objects with invalid schemas`, async () => {
const response = getMockBulkCreateResponse([obj3]);
client.bulk.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
);
const result = await savedObjectsRepository.bulkCreate([
obj3,
// @ts-expect-error - Title should be a string and is intentionally malformed for testing
{ ...obj3, id: 'three-again', attributes: { title: 123 } },
]);
expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object
expect(result.saved_objects).toEqual([
expect.objectContaining(obj3),
expect.objectContaining({
error: new Error(
'[attributes.title]: expected value of type [string] but got [number]: Bad Request'
),
id: 'three-again',
type: 'dashboard',
}),
]);
});
});
describe('migration', () => {
@ -2552,6 +2586,15 @@ describe('SavedObjectsRepository', () => {
expect(client.create).not.toHaveBeenCalled();
});
it(`throws when schema validation fails`, async () => {
await expect(
savedObjectsRepository.create('dashboard', { title: 123 })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"[attributes.title]: expected value of type [string] but got [number]: Bad Request"`
);
expect(client.create).not.toHaveBeenCalled();
});
it(`throws when there is a conflict from preflightCheckForCreate`, async () => {
mockPreflightCheckForCreate.mockResolvedValueOnce([
{ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, error: { type: 'unresolvableConflict' } }, // error type and metadata dont matter

View file

@ -64,6 +64,7 @@ import {
SavedObjectsMigrationVersion,
MutatingOperationRefreshSetting,
} from '../../types';
import { SavedObjectsTypeValidator } from '../../validation';
import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { internalBulkResolve, InternalBulkResolveError } from './internal_bulk_resolve';
import { validateConvertFilterToKueryNode } from './filter_utils';
@ -370,7 +371,15 @@ export class SavedObjectsRepository {
...(Array.isArray(references) && { references }),
});
const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc);
/**
* If a validation has been registered for this type, we run it against the migrated attributes.
* This is an imperfect solution because malformed attributes could have already caused the
* migration to fail, but it's the best we can do without devising a way to run validations
* inside the migration algorithm itself.
*/
this.validateObjectAttributes(type, migrated as SavedObjectSanitizedDoc<T>);
const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc<T>);
const requestParams = {
id: raw._id,
@ -526,23 +535,42 @@ export class SavedObjectsRepository {
versionProperties = getExpectedVersionProperties(version);
}
const migrated = this._migrator.migrateDocument({
id: object.id,
type: object.type,
attributes: object.attributes,
migrationVersion: object.migrationVersion,
coreMigrationVersion: object.coreMigrationVersion,
...(savedObjectNamespace && { namespace: savedObjectNamespace }),
...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }),
updated_at: time,
references: object.references || [],
originId: object.originId,
}) as SavedObjectSanitizedDoc<T>;
/**
* If a validation has been registered for this type, we run it against the migrated attributes.
* This is an imperfect solution because malformed attributes could have already caused the
* migration to fail, but it's the best we can do without devising a way to run validations
* inside the migration algorithm itself.
*/
try {
this.validateObjectAttributes(object.type, migrated);
} catch (error) {
return {
tag: 'Left',
value: {
id: object.id,
type: object.type,
error,
},
};
}
const expectedResult = {
esRequestIndex: bulkRequestIndexCounter++,
requestedId: object.id,
rawMigratedDoc: this._serializer.savedObjectToRaw(
this._migrator.migrateDocument({
id: object.id,
type: object.type,
attributes: object.attributes,
migrationVersion: object.migrationVersion,
coreMigrationVersion: object.coreMigrationVersion,
...(savedObjectNamespace && { namespace: savedObjectNamespace }),
...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }),
updated_at: time,
references: object.references || [],
originId: object.originId,
}) as SavedObjectSanitizedDoc
),
rawMigratedDoc: this._serializer.savedObjectToRaw(migrated),
};
bulkCreateParams.push(
@ -2278,6 +2306,26 @@ export class SavedObjectsRepository {
);
}
}
/** Validate a migrated doc against the registered saved object type's schema. */
private validateObjectAttributes(type: string, doc: SavedObjectSanitizedDoc) {
const savedObjectType = this._registry.getType(type);
if (!savedObjectType?.schemas) {
return;
}
const validator = new SavedObjectsTypeValidator({
logger: this._logger.get('type-validator'),
type,
validationMap: savedObjectType.schemas,
});
try {
validator.validate(this._migrator.kibanaVersion, doc);
} catch (error) {
throw SavedObjectsErrorHelpers.createBadRequestError(error.message);
}
}
}
/**

View file

@ -12,6 +12,7 @@ import { SavedObjectsTypeMappingDefinition } from './mappings';
import { SavedObjectMigrationMap } from './migrations';
import { SavedObjectsExportTransform } from './export';
import { SavedObjectsImportHook } from './import/types';
import { SavedObjectsValidationMap } from './validation';
export type {
SavedObjectsImportResponse,
@ -250,8 +251,6 @@ export type SavedObjectsClientContract = Pick<SavedObjectsClient, keyof SavedObj
export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic';
/**
* @remarks This is only internal for now, and will only be public when we expose the registerType API
*
* @public
*/
export interface SavedObjectsType<Attributes = any> {
@ -291,6 +290,14 @@ export interface SavedObjectsType<Attributes = any> {
* 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);
/**
* An optional schema that can be used to validate the attributes of the type.
*
* When provided, calls to {@link SavedObjectsClient.create | create} will be validated against this schema.
*
* See {@link SavedObjectsValidationMap} for more details.
*/
schemas?: SavedObjectsValidationMap | (() => SavedObjectsValidationMap);
/**
* If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this
* version.

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { SavedObjectsValidationMap, SavedObjectsValidationSpec } from './types';
export { SavedObjectsTypeValidator } from './validator';

View file

@ -0,0 +1,264 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import Fs from 'fs';
import Util from 'util';
import { Env } from '@kbn/config';
import { schema } from '@kbn/config-schema';
import { REPO_ROOT } from '@kbn/utils';
import { SavedObjectsType } from '../../types';
import { ISavedObjectsRepository } from '../../service/lib';
import { getEnvOptions } from '../../../config/mocks';
import { InternalCoreSetup, InternalCoreStart } from '../../../internal_types';
import { Root } from '../../../root';
import * as kbnTestServer from '../../../../test_helpers/kbn_server';
const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version;
const logFilePath = Path.join(__dirname, 'saved_object_type_validation.log');
const asyncUnlink = Util.promisify(Fs.unlink);
async function removeLogFile() {
// ignore errors if it doesn't exist
await asyncUnlink(logFilePath).catch(() => void 0);
}
function createRoot() {
return kbnTestServer.createRootWithCorePlugins(
{
migrations: {
skip: false,
},
logging: {
appenders: {
file: {
type: 'file',
fileName: logFilePath,
layout: {
type: 'json',
},
},
},
loggers: [
{
name: 'root',
appenders: ['file'],
},
],
},
},
{
oss: true,
}
);
}
// To keep this suite from running too long, we are only setting up ES once and registering
// a handful of SO types to test different scenarios. This means we need to take care when
// adding new tests, as ES will not be cleaned up in between each test run.
const savedObjectTypes: SavedObjectsType[] = [
{
name: 'schema-using-kbn-config',
hidden: false,
mappings: {
properties: {
a: { type: 'integer' },
b: { type: 'text' },
},
},
migrations: {
[kibanaVersion]: (doc) => doc,
},
namespaceType: 'agnostic',
schemas: {
[kibanaVersion]: schema.object({
a: schema.number(),
b: schema.string(),
}),
},
},
{
name: 'no-schema',
hidden: false,
mappings: {
properties: {
a: { type: 'integer' },
b: { type: 'text' },
},
},
migrations: {
[kibanaVersion]: (doc) => doc,
},
namespaceType: 'agnostic',
},
{
name: 'migration-error',
hidden: false,
mappings: {
properties: {
a: { type: 'integer' },
b: { type: 'text' },
},
},
migrations: {
[kibanaVersion]: (doc) => {
throw new Error('migration error'); // intentionally create a migration error
},
},
namespaceType: 'agnostic',
schemas: {
[kibanaVersion]: schema.object({
a: schema.number(),
b: schema.string(),
}),
},
},
];
describe('validates saved object types when a schema is provided', () => {
let esServer: kbnTestServer.TestElasticsearchUtils;
let root: Root;
let coreSetup: InternalCoreSetup;
let coreStart: InternalCoreStart;
let savedObjectsClient: ISavedObjectsRepository;
beforeAll(async () => {
await removeLogFile();
const { startES } = kbnTestServer.createTestServers({
adjustTimeout: (t: number) => jest.setTimeout(t),
settings: {
es: {
license: 'basic',
},
},
});
root = createRoot();
esServer = await startES();
await root.preboot();
coreSetup = await root.setup();
savedObjectTypes.forEach((type) => coreSetup.savedObjects.registerType(type));
coreStart = await root.start();
savedObjectsClient = coreStart.savedObjects.createInternalRepository();
});
afterAll(async () => {
if (root) {
await root.shutdown();
}
if (esServer) {
await esServer.stop();
}
await new Promise((resolve) => setTimeout(resolve, 10000));
});
it('does nothing when no schema is provided', async () => {
const { attributes } = await savedObjectsClient.create(
'no-schema',
{
a: 1,
b: 'heya',
},
{ migrationVersion: { bar: '7.16.0' } }
);
expect(attributes).toEqual(
expect.objectContaining({
a: 1,
b: 'heya',
})
);
});
it('is superseded by migration errors and does not run if a migration fails', async () => {
expect(async () => {
await savedObjectsClient.create(
'migration-error',
{
a: 1,
b: 2, // invalid, would throw validation error if migration didn't fail first
},
{ migrationVersion: { foo: '7.16.0' } }
);
}).rejects.toThrowErrorMatchingInlineSnapshot(
`"Migration function for version 8.1.0 threw an error"`
);
});
it('returns validation errors with bulkCreate', async () => {
const validObj = {
type: 'schema-using-kbn-config',
id: 'bulk-valid',
attributes: {
a: 1,
b: 'heya',
},
};
const invalidObj = {
type: 'schema-using-kbn-config',
id: 'bulk-invalid',
attributes: {
a: 'oops',
b: 'heya',
},
};
// @ts-expect-error - The invalidObj is intentionally malformed for testing
const results = await savedObjectsClient.bulkCreate([validObj, invalidObj]);
expect(results.saved_objects).toEqual([
expect.objectContaining(validObj),
expect.objectContaining({
error: new Error(
'[attributes.a]: expected value of type [number] but got [string]: Bad Request'
),
id: 'bulk-invalid',
type: 'schema-using-kbn-config',
}),
]);
});
describe('when validating with a config schema', () => {
it('throws when an invalid attribute is provided', async () => {
expect(async () => {
await savedObjectsClient.create(
'schema-using-kbn-config',
{
a: 1,
b: 2,
},
{ migrationVersion: { foo: '7.16.0' } }
);
}).rejects.toThrowErrorMatchingInlineSnapshot(
`"[attributes.b]: expected value of type [string] but got [number]: Bad Request"`
);
});
it('does not throw when valid attributes are provided', async () => {
const { attributes } = await savedObjectsClient.create(
'schema-using-kbn-config',
{
a: 1,
b: 'heya',
},
{ migrationVersion: { foo: '7.16.0' } }
);
expect(attributes).toEqual(
expect.objectContaining({
a: 1,
b: 'heya',
})
);
});
});
});

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { SavedObjectsValidationMap } from './';
import { SavedObjectSanitizedDoc } from '../serialization';
import { createSavedObjectSanitizedDocSchema } from './schema';
describe('Saved Objects type validation schema', () => {
const type = 'my-type';
const validationMap: SavedObjectsValidationMap = {
'1.0.0': schema.object({
foo: schema.string(),
}),
};
const createMockObject = (attributes: unknown): SavedObjectSanitizedDoc => ({
attributes,
id: 'test-id',
references: [],
type,
});
it('should validate attributes based on provided spec', () => {
const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']);
const data = createMockObject({ foo: 'heya' });
expect(() => objectSchema.validate(data)).not.toThrowError();
});
it('should fail if invalid attributes are provided', () => {
const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']);
const data = createMockObject({ foo: false });
expect(() => objectSchema.validate(data)).toThrowErrorMatchingInlineSnapshot(
`"[attributes.foo]: expected value of type [string] but got [boolean]"`
);
});
it('should validate top-level properties', () => {
const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']);
const data = createMockObject({ foo: 'heya' });
expect(() =>
objectSchema.validate({
...data,
id: 'abc-123',
type: 'dashboard',
references: [
{
name: 'ref_0',
type: 'visualization',
id: '123',
},
],
namespace: 'a',
namespaces: ['a', 'b'],
migrationVersion: {
dashboard: '1.0.0',
},
coreMigrationVersion: '1.0.0',
updated_at: '2022-01-05T03:17:07.183Z',
version: '2',
originId: 'def-456',
})
).not.toThrowError();
});
it('should fail if top-level properties are invalid', () => {
const objectSchema = createSavedObjectSanitizedDocSchema(validationMap['1.0.0']);
const data = createMockObject({ foo: 'heya' });
expect(() => objectSchema.validate({ ...data, id: false })).toThrowErrorMatchingInlineSnapshot(
`"[id]: expected value of type [string] but got [boolean]"`
);
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema, Type } from '@kbn/config-schema';
import { SavedObjectsValidationSpec } from './types';
import { SavedObjectSanitizedDoc } from '../serialization';
// We convert `SavedObjectSanitizedDoc` to its validation schema representation
// to ensure that we don't forget to keep the schema up-to-date. TS will complain
// if we update `SavedObjectSanitizedDoc` without making changes below.
type SavedObjectSanitizedDocSchema = {
[K in keyof Required<SavedObjectSanitizedDoc>]: Type<SavedObjectSanitizedDoc[K]>;
};
/**
* Takes a {@link SavedObjectsValidationSpec} and returns a full schema representing
* a {@link SavedObjectSanitizedDoc}, with the spec applied to the object's `attributes`.
*
* @internal
*/
export const createSavedObjectSanitizedDocSchema = (attributesSchema: SavedObjectsValidationSpec) =>
schema.object<SavedObjectSanitizedDocSchema>({
attributes: attributesSchema,
id: schema.string(),
type: schema.string(),
references: schema.arrayOf(
schema.object({
name: schema.string(),
type: schema.string(),
id: schema.string(),
}),
{ defaultValue: [] }
),
namespace: schema.maybe(schema.string()),
namespaces: schema.maybe(schema.arrayOf(schema.string())),
migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())),
coreMigrationVersion: schema.maybe(schema.string()),
updated_at: schema.maybe(schema.string()),
version: schema.maybe(schema.string()),
originId: schema.maybe(schema.string()),
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ObjectType } from '@kbn/config-schema';
/**
* Allows for validating properties using @kbn/config-schema validations.
*
* @public
*/
export type SavedObjectsValidationSpec = ObjectType;
/**
* A map of {@link SavedObjectsValidationSpec | validation specs} to be used for a given type.
* The map's keys must be valid semver versions.
*
* Any time you change the schema of a {@link SavedObjectsType}, you should add a new entry
* to this map for the Kibana version the change was introduced in.
*
* @example
* ```typescript
* const validationMap: SavedObjectsValidationMap = {
* '1.0.0': schema.object({
* foo: schema.string(),
* }),
* '2.0.0': schema.object({
* foo: schema.string({
* minLength: 2,
* validate(value) {
* if (!/^[a-z]+$/.test(value)) {
* return 'must be lowercase letters only';
* }
* }
* }),
* }),
* }
* ```
*
* @public
*/
export interface SavedObjectsValidationMap {
[version: string]: SavedObjectsValidationSpec;
}

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { SavedObjectsTypeValidator, SavedObjectsValidationMap } from './';
import { SavedObjectSanitizedDoc } from '../serialization';
import { loggerMock, MockedLogger } from '../../logging/logger.mock';
describe('Saved Objects type validator', () => {
let validator: SavedObjectsTypeValidator;
let logger: MockedLogger;
const type = 'my-type';
const validationMap: SavedObjectsValidationMap = {
'1.0.0': schema.object({
foo: schema.string(),
}),
};
const createMockObject = (attributes: Record<string, unknown>): SavedObjectSanitizedDoc => ({
attributes,
id: 'test-id',
references: [],
type,
});
beforeEach(() => {
logger = loggerMock.create();
validator = new SavedObjectsTypeValidator({ logger, type, validationMap });
});
afterEach(() => {
jest.clearAllMocks();
});
it('should do nothing if no matching validation could be found', () => {
const data = createMockObject({ foo: false });
expect(validator.validate('3.0.0', data)).toBeUndefined();
expect(logger.debug).not.toHaveBeenCalled();
});
it('should log when a validation fails', () => {
const data = createMockObject({ foo: false });
expect(() => validator.validate('1.0.0', data)).toThrowError();
expect(logger.warn).toHaveBeenCalledTimes(1);
});
it('should work when given valid values', () => {
const data = createMockObject({ foo: 'hi' });
expect(() => validator.validate('1.0.0', data)).not.toThrowError();
});
it('should throw an error when given invalid values', () => {
const data = createMockObject({ foo: false });
expect(() => validator.validate('1.0.0', data)).toThrowErrorMatchingInlineSnapshot(
`"[attributes.foo]: expected value of type [string] but got [boolean]"`
);
});
it('should throw an error if fields other than attributes are malformed', () => {
const data = createMockObject({ foo: 'hi' });
// @ts-expect-error Intentionally malformed object
data.updated_at = false;
expect(() => validator.validate('1.0.0', data)).toThrowErrorMatchingInlineSnapshot(
`"[updated_at]: expected value of type [string] but got [boolean]"`
);
});
it('works when the validation map is a function', () => {
const fnValidationMap: () => SavedObjectsValidationMap = () => validationMap;
validator = new SavedObjectsTypeValidator({
logger,
type,
validationMap: fnValidationMap,
});
const data = createMockObject({ foo: 'hi' });
expect(() => validator.validate('1.0.0', data)).not.toThrowError();
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createSavedObjectSanitizedDocSchema } from './schema';
import { SavedObjectsValidationMap } from './types';
import { SavedObjectSanitizedDoc } from '../serialization';
import { Logger } from '../../logging';
/**
* Helper class that takes a {@link SavedObjectsValidationMap} and runs validations for a
* given type based on the provided Kibana version.
*
* @internal
*/
export class SavedObjectsTypeValidator {
private readonly log: Logger;
private readonly type: string;
private readonly validationMap: SavedObjectsValidationMap;
constructor({
logger,
type,
validationMap,
}: {
logger: Logger;
type: string;
validationMap: SavedObjectsValidationMap | (() => SavedObjectsValidationMap);
}) {
this.log = logger;
this.type = type;
this.validationMap = typeof validationMap === 'function' ? validationMap() : validationMap;
}
public validate(objectVersion: string, data: SavedObjectSanitizedDoc): void {
const validationRule = this.validationMap[objectVersion];
if (!validationRule) {
return; // no matching validation rule could be found; proceed without validating
}
try {
const validationSchema = createSavedObjectSanitizedDocSchema(validationRule);
validationSchema.validate(data);
} catch (e) {
this.log.warn(
`Error validating object of type [${this.type}] against version [${objectVersion}]`
);
throw e;
}
}
}

View file

@ -2747,7 +2747,7 @@ export class SavedObjectsSerializer {
export interface SavedObjectsServiceSetup {
addClientWrapper: (priority: number, id: string, factory: SavedObjectsClientWrapperFactory) => void;
getKibanaIndex: () => string;
registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
registerType: <Attributes extends SavedObjectAttributes = any>(type: SavedObjectsType<Attributes>) => void;
setClientFactoryProvider: (clientFactoryProvider: SavedObjectsClientFactoryProvider) => void;
}
@ -2784,6 +2784,7 @@ export interface SavedObjectsType<Attributes = any> {
migrations?: SavedObjectMigrationMap | (() => SavedObjectMigrationMap);
name: string;
namespaceType: SavedObjectsNamespaceType;
schemas?: SavedObjectsValidationMap | (() => SavedObjectsValidationMap);
}
// @public
@ -2866,6 +2867,15 @@ export class SavedObjectsUtils {
static namespaceStringToId: (namespace: string) => string | undefined;
}
// @public
export interface SavedObjectsValidationMap {
// (undocumented)
[version: string]: SavedObjectsValidationSpec;
}
// @public
export type SavedObjectsValidationSpec = ObjectType;
// Warning: (ae-extra-release-tag) The doc comment should not contain more than one release tag
//
// @public