[Saved Objects] Provide ability to remove SO type from global SO HTTP API without hiding from the client (#149166)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
resolves https://github.com/elastic/kibana/issues/147150
This commit is contained in:
Christiane (Tina) Heiligers 2023-01-23 15:04:24 -07:00 committed by GitHub
parent 2dd13289e3
commit f7b25f5e46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 5035 additions and 28 deletions

View file

@ -257,3 +257,39 @@ the error should be verbose and informative so that the corrupt document can be
### Testing Migrations
Bugs in a migration function cause downtime for our users and therefore have a very high impact. Follow the <DocLink id="kibDevTutorialTestingPlugins" section="saved-objects-migrations" text="Saved Object migrations section in the plugin testing guide"/>.
### How to opt-out of the global savedObjects APIs?
There are 2 options, depending on the amount of flexibility you need:
For complete control over your HTTP APIs and custom handling, declare your type as `hidden`, as shown in the example.
The other option that allows you to build your own HTTP APIs and still use the client as-is is to declare your type as hidden from the global saved objects HTTP APIs as `hiddenFromHttpApis: true`
```ts
import { SavedObjectsType } from 'src/core/server';
export const foo: SavedObjectsType = {
name: 'foo',
hidden: false, [1]
hiddenFromHttpApis: true, [2]
namespaceType: 'multiple-isolated',
mappings: {
dynamic: false,
properties: {
description: {
type: 'text',
},
hits: {
type: 'integer',
},
},
},
migrations: {
'1.0.0': migratedashboardVisualizationToV1,
'2.0.0': migratedashboardVisualizationToV2,
},
};
```
[1] Needs to be `false` to use the `hiddenFromHttpApis` option
[2] Set this to `true` to build your own HTTP API and have complete control over the route handler.

View file

@ -271,6 +271,11 @@ in this code, there's really no reason not to aim for 100% test code coverage.
==== Type visibility
It is recommended that plugins only expose Saved Object types that are necessary.
That is so to provide better backward compatibility.
There are two options to register a type: either as completely unexposed to the global Saved Objects HTTP APIs and client or to only expose it to the client but not to the APIs.
===== Hidden types
In case when the type is not hidden, it will be exposed via the global Saved Objects HTTP API.
That brings the limitation of introducing backward incompatible changes as there might be a service consuming the API.
@ -302,6 +307,40 @@ class SomePlugin implements Plugin {
}
----
===== Hidden from the HTTP APIs
When a saved object is registered as hidden from the HTTP APIs, it will remain exposed to the global Saved Objects client:
[source,typescript]
----
import { SavedObjectsType } from 'src/core/server';
export const myCustomVisualization: SavedObjectsType = {
name: 'my_custom_visualization', // <1>
hidden: false,
hiddenFromHttpApis: true, // <2>
namespaceType: 'multiple-isolated',
mappings: {
dynamic: false,
properties: {
description: {
type: 'text',
},
hits: {
type: 'integer',
},
},
},
migrations: {
'1.0.0': migrateMyCustomVisualizationToV1,
'2.0.0': migrateMyCustomVisualizationToV2,
},
};
----
<1> MyCustomVisualization types have their own domain-specific HTTP API's that leverage the global Saved Objects client
<2> This field determines "hidden from http apis" behavior -- any attempts to use the global Saved Objects HTTP APIs will throw errors
=== Client side usage
==== References

View file

@ -139,6 +139,67 @@ describe('SavedObjectTypeRegistry', () => {
}).not.toThrow();
});
it('throws when `hidden` is true and `hiddenFromHttpApis` is false', () => {
expect(() => {
registry.registerType(
createType({
name: 'typeHiddenA',
hidden: true,
hiddenFromHttpApis: false,
})
);
}).toThrowErrorMatchingInlineSnapshot(
`"Type typeHiddenA: 'hiddenFromHttpApis' cannot be 'false' when specifying 'hidden' as 'true'"`
);
expect(() => {
registry.registerType(
createType({
name: 'typeHiddenA',
hidden: true,
hiddenFromHttpApis: true,
})
);
}).not.toThrow();
expect(() => {
registry.registerType(
createType({
name: 'typeHiddenA2',
hidden: true,
})
);
}).not.toThrow();
expect(() => {
registry.registerType(
createType({
name: 'typeVisibleA',
hidden: false,
})
);
}).not.toThrow();
expect(() => {
registry.registerType(
createType({
name: 'typeVisibleA1',
hidden: false,
hiddenFromHttpApis: false,
})
);
}).not.toThrow();
expect(() => {
registry.registerType(
createType({
name: 'typeVisibleA2',
hidden: false,
hiddenFromHttpApis: true,
})
);
}).not.toThrow();
});
// TODO: same test with 'onImport'
});
@ -383,6 +444,22 @@ describe('SavedObjectTypeRegistry', () => {
});
});
describe('#isHiddenFromHttpApis', () => {
it('returns correct value for the type', () => {
registry.registerType(createType({ name: 'typeA', hiddenFromHttpApis: true }));
registry.registerType(createType({ name: 'typeB', hiddenFromHttpApis: false }));
expect(registry.isHiddenFromHttpApis('typeA')).toEqual(true);
expect(registry.isHiddenFromHttpApis('typeB')).toEqual(false);
});
it('returns true when the type is not registered', () => {
registry.registerType(createType({ name: 'typeA', hiddenFromHttpApis: false }));
registry.registerType(createType({ name: 'typeB', hiddenFromHttpApis: true }));
expect(registry.isHiddenFromHttpApis('unknownType')).toEqual(false);
});
});
describe('#getIndex', () => {
it('returns correct value for the type', () => {
registry.registerType(createType({ name: 'typeA', indexPattern: '.custom-index' }));

View file

@ -41,6 +41,11 @@ export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry {
return [...this.types.values()].filter((type) => !this.isHidden(type.name));
}
/** {@inheritDoc ISavedObjectTypeRegistry.getVisibleToHttpApisTypes} */
public getVisibleToHttpApisTypes() {
return [...this.types.values()].filter((type) => !this.isHiddenFromHttpApis(type.name));
}
/** {@inheritDoc ISavedObjectTypeRegistry.getAllTypes} */
public getAllTypes() {
return [...this.types.values()];
@ -78,6 +83,11 @@ export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry {
return this.types.get(type)?.hidden ?? false;
}
/** {@inheritDoc ISavedObjectTypeRegistry.isHiddenFromHttpApi} */
public isHiddenFromHttpApis(type: string) {
return !!this.types.get(type)?.hiddenFromHttpApis;
}
/** {@inheritDoc ISavedObjectTypeRegistry.getType} */
public getIndex(type: string) {
return this.types.get(type)?.indexPattern;
@ -89,7 +99,7 @@ export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry {
}
}
const validateType = ({ name, management }: SavedObjectsType) => {
const validateType = ({ name, management, hidden, hiddenFromHttpApis }: SavedObjectsType) => {
if (management) {
if (management.onExport && !management.importableAndExportable) {
throw new Error(
@ -102,4 +112,10 @@ const validateType = ({ name, management }: SavedObjectsType) => {
);
}
}
// throw error if a type is registered as `hidden:true` and `hiddenFromHttpApis:false` explicitly
if (hidden === true && hiddenFromHttpApis === false) {
throw new Error(
`Type ${name}: 'hiddenFromHttpApis' cannot be 'false' when specifying 'hidden' as 'true'`
);
}
};

View file

@ -16,6 +16,7 @@ const createRegistryMock = (): jest.Mocked<
registerType: jest.fn(),
getType: jest.fn(),
getVisibleTypes: jest.fn(),
getVisibleToHttpApisTypes: jest.fn(),
getAllTypes: jest.fn(),
getImportableAndExportableTypes: jest.fn(),
isNamespaceAgnostic: jest.fn(),
@ -23,6 +24,7 @@ const createRegistryMock = (): jest.Mocked<
isMultiNamespace: jest.fn(),
isShareable: jest.fn(),
isHidden: jest.fn(),
isHiddenFromHttpApis: jest.fn(),
getIndex: jest.fn(),
isImportableAndExportable: jest.fn(),
};
@ -33,6 +35,7 @@ const createRegistryMock = (): jest.Mocked<
mock.getIndex.mockReturnValue('.kibana-test');
mock.getIndex.mockReturnValue('.kibana-test');
mock.isHidden.mockReturnValue(false);
mock.isHiddenFromHttpApis.mockReturnValue(false);
mock.isNamespaceAgnostic.mockImplementation((type: string) => type === 'global');
mock.isSingleNamespace.mockImplementation(
(type: string) => type !== 'global' && type !== 'shared'
@ -40,6 +43,7 @@ const createRegistryMock = (): jest.Mocked<
mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared');
mock.isShareable.mockImplementation((type: string) => type === 'shared');
mock.isImportableAndExportable.mockReturnValue(true);
mock.getVisibleToHttpApisTypes.mockReturnValue(false);
return mock;
};

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfAnyTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -55,6 +55,9 @@ export const registerBulkCreateRoute = (
usageStatsClient.incrementSavedObjectsBulkCreate({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
const typesToCheck = [...new Set(req.body.map(({ type }) => type))];
throwIfAnyTypeNotVisibleByAPI(typesToCheck, savedObjects.typeRegistry);
const result = await savedObjects.client.bulkCreate(req.body, { overwrite });
return res.ok({ body: result });
})

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfAnyTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -41,6 +41,9 @@ export const registerBulkDeleteRoute = (
const { savedObjects } = await context.core;
const typesToCheck = [...new Set(req.body.map(({ type }) => type))];
throwIfAnyTypeNotVisibleByAPI(typesToCheck, savedObjects.typeRegistry);
const statuses = await savedObjects.client.bulkDelete(req.body, { force });
return res.ok({ body: statuses });
})

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfAnyTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -38,6 +38,9 @@ export const registerBulkGetRoute = (
usageStatsClient.incrementSavedObjectsBulkGet({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
const typesToCheck = [...new Set(req.body.map(({ type }) => type))];
throwIfAnyTypeNotVisibleByAPI(typesToCheck, savedObjects.typeRegistry);
const result = await savedObjects.client.bulkGet(req.body);
return res.ok({ body: result });
})

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfAnyTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -36,6 +36,8 @@ export const registerBulkResolveRoute = (
usageStatsClient.incrementSavedObjectsBulkResolve({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
const typesToCheck = [...new Set(req.body.map(({ type }) => type))];
throwIfAnyTypeNotVisibleByAPI(typesToCheck, savedObjects.typeRegistry);
const result = await savedObjects.client.bulkResolve(req.body);
return res.ok({ body: result });
})

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfAnyTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -48,6 +48,10 @@ export const registerBulkUpdateRoute = (
usageStatsClient.incrementSavedObjectsBulkUpdate({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
const typesToCheck = [...new Set(req.body.map(({ type }) => type))];
throwIfAnyTypeNotVisibleByAPI(typesToCheck, savedObjects.typeRegistry);
const savedObject = await savedObjects.client.bulkUpdate(req.body);
return res.ok({ body: savedObject });
})

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -56,6 +56,10 @@ export const registerCreateRoute = (
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsCreate({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
throwIfTypeNotVisibleByAPI(type, savedObjects.typeRegistry);
const options = {
id,
overwrite,
@ -64,7 +68,6 @@ export const registerCreateRoute = (
references,
initialNamespaces,
};
const { savedObjects } = await context.core;
const result = await savedObjects.client.create(type, attributes, options);
return res.ok({ body: result });
})

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -35,10 +35,11 @@ export const registerDeleteRoute = (
catchAndReturnBoomErrors(async (context, req, res) => {
const { type, id } = req.params;
const { force } = req.query;
const { getClient } = (await context.core).savedObjects;
const { getClient, typeRegistry } = (await context.core).savedObjects;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsDelete({ request: req }).catch(() => {});
throwIfTypeNotVisibleByAPI(type, typeRegistry);
const client = getClient();
const result = await client.delete(type, id, { force });

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwOnHttpHiddenTypes } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -67,7 +67,7 @@ export const registerFindRoute = (
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsFind({ request: req }).catch(() => {});
// manually validation to avoid using JSON.parse twice
// manually validate to avoid using JSON.parse twice
let aggs;
if (query.aggs) {
try {
@ -81,10 +81,25 @@ export const registerFindRoute = (
}
}
const { savedObjects } = await context.core;
// check if registered type(s)are exposed to the global SO Http API's.
const findForTypes = Array.isArray(query.type) ? query.type : [query.type];
const unsupportedTypes = [...new Set(findForTypes)].filter((tname) => {
const fullType = savedObjects.typeRegistry.getType(tname);
// pass unknown types through to the registry to handle
if (!fullType?.hidden && fullType?.hiddenFromHttpApis) {
return fullType.name;
}
});
if (unsupportedTypes.length > 0) {
throwOnHttpHiddenTypes(unsupportedTypes);
}
const result = await savedObjects.client.find({
perPage: query.per_page,
page: query.page,
type: Array.isArray(query.type) ? query.type : [query.type],
type: findForTypes,
search: query.search,
defaultSearchOperator: query.default_search_operator,
searchFields:

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -36,6 +36,8 @@ export const registerGetRoute = (
usageStatsClient.incrementSavedObjectsGet({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
throwIfTypeNotVisibleByAPI(type, savedObjects.typeRegistry);
const object = await savedObjects.client.get(type, id);
return res.ok({ body: object });
})

View file

@ -9,6 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { throwIfTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -35,6 +36,8 @@ export const registerResolveRoute = (
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsResolve({ request: req }).catch(() => {});
throwIfTypeNotVisibleByAPI(type, savedObjects.typeRegistry);
const result = await savedObjects.client.resolve(type, id);
return res.ok({ body: result });
})

View file

@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema';
import type { SavedObjectsUpdateOptions } from '@kbn/core-saved-objects-api-server';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
import { catchAndReturnBoomErrors, throwIfTypeNotVisibleByAPI } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
@ -51,8 +51,10 @@ export const registerUpdateRoute = (
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsUpdate({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
throwIfTypeNotVisibleByAPI(type, savedObjects.typeRegistry);
const result = await savedObjects.client.update(type, id, attributes, options);
return res.ok({ body: result });
})

View file

@ -6,7 +6,15 @@
* Side Public License, v 1.
*/
import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils';
import {
createSavedObjectsStreamFromNdJson,
validateTypes,
validateObjects,
throwOnHttpHiddenTypes,
throwOnGloballyHiddenTypes,
throwIfTypeNotVisibleByAPI,
throwIfAnyTypeNotVisibleByAPI,
} from './utils';
import { Readable } from 'stream';
import { createPromiseFromStreams, createConcatStream } from '@kbn/utils';
import { catchAndReturnBoomErrors } from './utils';
@ -18,6 +26,7 @@ import type {
KibanaResponseFactory,
} from '@kbn/core-http-server';
import { kibanaResponseFactory } from '@kbn/core-http-router-server-internal';
import { typeRegistryInstanceMock } from '../saved_objects_service.test.mocks';
async function readStreamToCompletion(stream: Readable) {
return createPromiseFromStreams([stream, createConcatStream([])]);
@ -236,3 +245,99 @@ describe('catchAndReturnBoomErrors', () => {
);
});
});
describe('throwOnHttpHiddenTypes', () => {
it('should throw on types hidden from the HTTP Apis', () => {
expect(() => {
throwOnHttpHiddenTypes(['not-allowed-type']);
}).toThrowErrorMatchingInlineSnapshot(
`"Unsupported saved object type(s): not-allowed-type: Bad Request"`
);
expect(() => {
throwOnHttpHiddenTypes(['index-pattern', 'not-allowed-type', 'not-allowed-type-2']);
}).toThrowErrorMatchingInlineSnapshot(
`"Unsupported saved object type(s): index-pattern, not-allowed-type, not-allowed-type-2: Bad Request"`
);
});
it("returns if there aren't any types provided to check", () => {
expect(() => {
throwOnHttpHiddenTypes([]);
}).not.toThrowError();
});
});
describe('throwOnGloballyHiddenTypes', () => {
const httpVisibleTypes = ['config', 'index-pattern', 'dashboard'];
it('throws if some objects are not globally visible', () => {
expect(() => {
throwOnGloballyHiddenTypes(httpVisibleTypes, ['not-allowed-type']);
}).toThrowErrorMatchingInlineSnapshot(
`"Unsupported saved object type(s): not-allowed-type: Bad Request"`
);
});
it("returns if there aren't any types provided to check", () => {
expect(() => {
throwOnGloballyHiddenTypes(httpVisibleTypes, []);
}).not.toThrowError();
});
});
describe('throwIfTypeNotVisibleByAPI', () => {
const registry = typeRegistryInstanceMock;
registry.getType.mockImplementation((name: string) => {
return {
name,
hidden: name === 'hidden' ? true : false,
hiddenFromHttpApis: name === 'hiddenFromHttpApis' ? true : false,
namespaceType: 'multiple-isolated',
mappings: { properties: {} },
};
});
it('throws if a type is not visible by to the HTTP APIs', () => {
expect(() =>
throwIfTypeNotVisibleByAPI('hiddenFromHttpApis', registry)
).toThrowErrorMatchingInlineSnapshot(
`"Unsupported saved object type: 'hiddenFromHttpApis': Bad Request"`
);
});
it('does not throw when a type is not hidden from the HTTP APIS', () => {
expect(() => throwIfTypeNotVisibleByAPI('hidden', registry)).not.toThrowError();
});
it('does not throw on visible types', () => {
expect(() => throwIfTypeNotVisibleByAPI('config', registry)).not.toThrowError();
});
});
describe('throwIfAnyTypeNotVisibleByAPI', () => {
const registry = typeRegistryInstanceMock;
registry.getType.mockImplementation((name: string) => {
return {
name,
hidden: name === 'hidden' ? true : false,
hiddenFromHttpApis: name === 'hiddenFromHttpApis' ? true : false,
namespaceType: 'multiple-isolated',
mappings: { properties: {} },
};
});
it('throws if the provided types contains any that are not visible by to the HTTP APIs', () => {
expect(() =>
throwIfAnyTypeNotVisibleByAPI(['hiddenFromHttpApis'], registry)
).toThrowErrorMatchingInlineSnapshot(
`"Unsupported saved object type(s): hiddenFromHttpApis: Bad Request"`
);
});
it('does not throw when a type is not hidden from the HTTP APIS', () => {
expect(() => throwIfAnyTypeNotVisibleByAPI(['hidden'], registry)).not.toThrowError();
});
it('does not throw on visible types', () => {
expect(() => throwIfAnyTypeNotVisibleByAPI(['config'], registry)).not.toThrowError();
});
});

View file

@ -18,7 +18,11 @@ import {
import Boom from '@hapi/boom';
import type { RequestHandlerWrapper } from '@kbn/core-http-server';
import type { SavedObject } from '@kbn/core-saved-objects-common';
import type { SavedObjectsExportResultDetails } from '@kbn/core-saved-objects-server';
import type {
ISavedObjectTypeRegistry,
SavedObjectsExportResultDetails,
} from '@kbn/core-saved-objects-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) {
const savedObjects = await createPromiseFromStreams([
@ -82,3 +86,69 @@ export const catchAndReturnBoomErrors: RequestHandlerWrapper = (handler) => {
}
};
};
/**
*
* @param {string[]} exposedVisibleTypes all registered types with hidden:false and hiddenFromHttpApis:false|undefined
* @param {string[]} typesToCheck saved object types provided to the httpApi request
*/
export function throwOnGloballyHiddenTypes(
allHttpApisVisibleTypes: string[],
typesToCheck: string[]
) {
if (!typesToCheck.length) {
return;
}
const denyRequestForTypes = typesToCheck.filter(
(type: string) => !allHttpApisVisibleTypes.includes(type)
);
if (denyRequestForTypes.length > 0) {
throw SavedObjectsErrorHelpers.createBadRequestError(
`Unsupported saved object type(s): ${denyRequestForTypes.join(', ')}`
);
}
}
/**
* @param {string[]} unsupportedTypes saved object types registered with hidden=false and hiddenFromHttpApis=true
*/
export function throwOnHttpHiddenTypes(unsupportedTypes: string[]) {
if (unsupportedTypes.length > 0) {
throw SavedObjectsErrorHelpers.createBadRequestError(
`Unsupported saved object type(s): ${unsupportedTypes.join(', ')}`
);
}
}
/**
* @param {string[]} type saved object type
* @param {ISavedObjectTypeRegistry} registry the saved object type registry
*/
export function throwIfTypeNotVisibleByAPI(type: string, registry: ISavedObjectTypeRegistry) {
if (!type) return;
const fullType = registry.getType(type);
if (!fullType?.hidden && fullType?.hiddenFromHttpApis) {
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
}
}
export function throwIfAnyTypeNotVisibleByAPI(
typesToCheck: string[],
registry: ISavedObjectTypeRegistry
) {
const unsupportedTypes = typesToCheck.filter((tname) => {
const fullType = registry.getType(tname);
if (!fullType?.hidden && fullType?.hiddenFromHttpApis) {
return fullType.name;
}
});
if (unsupportedTypes.length > 0) {
throwOnHttpHiddenTypes(unsupportedTypes);
}
}
export interface BulkGetItem {
type: string;
id: string;
fields?: string[];
namespaces?: string[];
}

View file

@ -46,6 +46,7 @@
"@kbn/core-elasticsearch-server-mocks",
"@kbn/utils",
"@kbn/core-http-router-server-internal",
"@kbn/core-saved-objects-utils-server",
],
"exclude": [
"target/**/*",

View file

@ -33,6 +33,14 @@ export interface SavedObjectsType<Attributes = any> {
* See {@link SavedObjectsServiceStart.createInternalRepository | createInternalRepository}.
*/
hidden: boolean;
/**
* Is the type hidden from the http APIs. If `hiddenFromHttpApis:true`, repositories will have access to the type but the type is not exposed via the HTTP APIs.
* It is recommended to hide types registered with 'hidden=false' from the httpApis for backward compatibility in the HTTP layer.
*
* @remarks Setting this property for hidden types is not recommended and will fail validation if set to `false`.
* @internalRemarks Using 'hiddenFromHttpApis' is an alternative to registering types as `hidden:true` to hide a type from the HTTP APIs without effecting repositories access.
*/
hiddenFromHttpApis?: boolean;
/**
* The {@link SavedObjectsNamespaceType | namespace type} for the type.
*/

View file

@ -24,6 +24,13 @@ export interface ISavedObjectTypeRegistry {
*/
getVisibleTypes(): SavedObjectsType[];
/**
* Returns all visible {@link SavedObjectsType | types} exposed to the global SO HTTP APIs
*
* A visibleToHttpApis type is a type that doesn't explicitly define `hidden=true` nor `hiddenFromHttpApis=true` during registration.
*/
getVisibleToHttpApisTypes(): SavedObjectsType[];
/**
* Return all {@link SavedObjectsType | types} currently registered, including the hidden ones.
*
@ -66,6 +73,11 @@ export interface ISavedObjectTypeRegistry {
*/
isHidden(type: string): boolean;
/**
* Returns the `hiddenFromHttpApis` property for a given type, or `false` if
* the type is not registered
*/
isHiddenFromHttpApis(type: string): boolean;
/**
* Returns the `indexPattern` property for given type, or `undefined` if
* the type is not registered.

View file

@ -8,3 +8,4 @@
export { setupServer } from './src/setup_server';
export { createExportableType } from './src/create_exportable_type';
export { createHiddenTypeVariants } from './src/create_hidden_type_variants';

View file

@ -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
* 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 { SavedObjectsType } from '@kbn/core-saved-objects-server';
export const createHiddenTypeVariants = (createOptions: {
name: string;
hide?: boolean;
hideFromHttpApis?: boolean;
}): SavedObjectsType => {
return {
name: createOptions.name,
hidden: createOptions.hide ?? false,
hiddenFromHttpApis: createOptions.hideFromHttpApis ?? undefined,
namespaceType: createOptions.name === 'index-pattern' ? 'multiple' : 'single',
mappings: {
properties: {},
},
};
};

View file

@ -18,10 +18,15 @@ import {
registerBulkCreateRoute,
type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('POST /api/saved_objects/_bulk_create', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -34,6 +39,12 @@ describe('POST /api/saved_objects/_bulk_create', () => {
savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: [] });
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -131,4 +142,23 @@ describe('POST /api/saved_objects/_bulk_create', () => {
const args = savedObjectsClient.bulkCreate.mock.calls[0];
expect(args[1]).toEqual({ overwrite: true });
});
it('returns with status 400 when a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_create')
.send([
{
id: 'hiddenID',
type: 'hidden-from-http',
attributes: {
title: 'bar',
},
references: [],
},
])
.expect(400);
expect(result.body.message).toContain(
'Unsupported saved object type(s): hidden-from-http: Bad Request'
);
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerBulkDeleteRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('POST /api/saved_objects/_bulk_delete', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -35,6 +40,13 @@ describe('POST /api/saved_objects/_bulk_delete', () => {
savedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [],
});
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -94,4 +106,31 @@ describe('POST /api/saved_objects/_bulk_delete', () => {
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith(docs, { force: true });
});
it('returns with status 400 when a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_delete')
.send([
{
id: 'hiddenID',
type: 'hidden-from-http',
},
])
.expect(400);
expect(result.body.message).toContain('Unsupported saved object type(s):');
});
it('returns with status 400 with `force` when a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_delete')
.send([
{
id: 'hiddenID',
type: 'hidden-from-http',
},
])
.query({ force: true })
.expect(400);
expect(result.body.message).toContain('Unsupported saved object type(s):');
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerBulkGetRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('POST /api/saved_objects/_bulk_get', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -35,6 +40,12 @@ describe('POST /api/saved_objects/_bulk_get', () => {
savedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [],
});
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -96,4 +107,17 @@ describe('POST /api/saved_objects/_bulk_get', () => {
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(docs);
});
it('returns with status 400 when a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_get')
.send([
{
id: 'hiddenID',
type: 'hidden-from-http',
},
])
.expect(400);
expect(result.body.message).toContain('Unsupported saved object type(s):');
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerBulkResolveRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('POST /api/saved_objects/_bulk_resolve', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -35,6 +40,13 @@ describe('POST /api/saved_objects/_bulk_resolve', () => {
savedObjectsClient.bulkResolve.mockResolvedValue({
resolved_objects: [],
});
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -99,4 +111,17 @@ describe('POST /api/saved_objects/_bulk_resolve', () => {
expect(savedObjectsClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkResolve).toHaveBeenCalledWith(docs);
});
it('returns with status 400 when a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_resolve')
.send([
{
id: 'hiddenID',
type: 'hidden-from-http',
},
])
.expect(400);
expect(result.body.message).toContain('Unsupported saved object type(s):');
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerBulkUpdateRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,13 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'visualization', hide: false },
{ name: 'dashboard', hide: false },
{ name: 'index-pattern', hide: false },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('PUT /api/saved_objects/_bulk_update', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -30,8 +37,15 @@ describe('PUT /api/saved_objects/_bulk_update', () => {
beforeEach(async () => {
({ server, httpSetup, handlerContext } = await setupServer());
savedObjectsClient = handlerContext.savedObjects.client;
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -139,4 +153,20 @@ describe('PUT /api/saved_objects/_bulk_update', () => {
},
]);
});
it('returns with status 400 when a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.put('/api/saved_objects/_bulk_update')
.send([
{
type: 'hidden-from-http',
id: 'hiddenID',
attributes: {
title: 'bar',
},
},
])
.expect(400);
expect(result.body.message).toContain('Unsupported saved object type(s):');
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { setupServer, createHiddenTypeVariants } from '@kbn/core-test-helpers-test-utils';
import {
registerCreateRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-type', hide: true },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('POST /api/saved_objects/{type}', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -49,6 +54,12 @@ describe('POST /api/saved_objects/{type}', () => {
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerCreateRoute(router, { coreUsageData });
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
await server.start();
});
@ -121,4 +132,17 @@ describe('POST /api/saved_objects/{type}', () => {
{ overwrite: false, id: 'logstash-*' },
]);
});
it('returns with status 400 if the type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/hidden-from-http')
.send({
attributes: {
properties: {},
},
})
.expect(400);
expect(result.body.message).toContain("Unsupported saved object type: 'hidden-from-http'");
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerDeleteRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,12 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-type', hide: true },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('DELETE /api/saved_objects/{type}/{id}', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -32,6 +38,11 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => {
({ server, httpSetup, handlerContext } = await setupServer());
savedObjectsClient = handlerContext.savedObjects.getClient();
handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient);
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
@ -78,4 +89,19 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => {
force: true,
});
});
it('returns with status 400 if a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.delete('/api/saved_objects/hidden-from-http/hiddenId')
.expect(400);
expect(result.body.message).toContain("Unsupported saved object type: 'hidden-from-http'");
});
it('returns with status 400 if a type is hidden from the HTTP APIs with `force` option', async () => {
const result = await supertest(httpSetup.server.listener)
.delete('/api/saved_objects/hidden-from-http/hiddenId')
.query({ force: true })
.expect(400);
expect(result.body.message).toContain("Unsupported saved object type: 'hidden-from-http'");
});
});

View file

@ -15,7 +15,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerFindRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -23,6 +23,15 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'visualization', hide: false },
{ name: 'dashboard', hide: false },
{ name: 'foo', hide: false },
{ name: 'bar', hide: false },
{ name: 'hidden-type', hide: true },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('GET /api/saved_objects/_find', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -39,6 +48,13 @@ describe('GET /api/saved_objects/_find', () => {
beforeEach(async () => {
({ server, httpSetup, handlerContext } = await setupServer());
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.find.mockResolvedValue(clientResponse);
@ -67,6 +83,33 @@ describe('GET /api/saved_objects/_find', () => {
);
});
it('returns with status 400 when type is hidden from the HTTP APIs', async () => {
const findResponse = {
error: 'Bad Request',
message: 'Unsupported saved object type(s): hidden-from-http: Bad Request',
statusCode: 400,
};
const result = await supertest(httpSetup.server.listener)
.get('/api/saved_objects/_find?type=hidden-from-http')
.expect(400);
expect(result.body).toEqual(findResponse);
});
it('returns with status 200 when type is hidden', async () => {
const findResponse = {
total: 0,
per_page: 0,
page: 0,
saved_objects: [],
};
const result = await supertest(httpSetup.server.listener)
.get('/api/saved_objects/_find?type=hidden-type')
.expect(200);
expect(result.body).toEqual(findResponse);
});
it('formats successful response and records usage stats', async () => {
const findResponse = {
total: 2,

View file

@ -22,9 +22,16 @@ import {
registerGetRoute,
type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal';
import { createHiddenTypeVariants } from '@kbn/core-test-helpers-test-utils';
const coreId = Symbol('core');
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-type', hide: true },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('GET /api/saved_objects/{type}/{id}', () => {
let server: HttpService;
let httpSetup: InternalHttpServiceSetup;
@ -44,6 +51,12 @@ describe('GET /api/saved_objects/{type}/{id}', () => {
});
handlerContext = coreMock.createRequestHandlerContext();
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
savedObjectsClient = handlerContext.savedObjects.client;
httpSetup.registerRouteHandlerContext<InternalSavedObjectsRequestHandlerContext, 'core'>(
@ -101,4 +114,11 @@ describe('GET /api/saved_objects/{type}/{id}', () => {
const args = savedObjectsClient.get.mock.calls[0];
expect(args).toEqual(['index-pattern', 'logstash-*']);
});
it('returns with status 400 when a type is hidden from the http APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.get('/api/saved_objects/hidden-from-http/hiddenId')
.expect(400);
expect(result.body.message).toContain("Unsupported saved object type: 'hidden-from-http'");
});
});

View file

@ -22,9 +22,16 @@ import {
registerResolveRoute,
type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal';
import { createHiddenTypeVariants } from '@kbn/core-test-helpers-test-utils';
const coreId = Symbol('core');
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-type', hide: true },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
let server: HttpService;
let httpSetup: InternalHttpServiceSetup;
@ -44,6 +51,13 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
});
handlerContext = coreMock.createRequestHandlerContext();
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
savedObjectsClient = handlerContext.savedObjects.client;
httpSetup.registerRouteHandlerContext<InternalSavedObjectsRequestHandlerContext, 'core'>(
@ -101,4 +115,11 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
const args = savedObjectsClient.resolve.mock.calls[0];
expect(args).toEqual(['index-pattern', 'logstash-*']);
});
it('returns with status 400 is a type is hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.get('/api/saved_objects/resolve/hidden-from-http/hiddenId')
.expect(400);
expect(result.body.message).toContain("Unsupported saved object type: 'hidden-from-http'");
});
});

View file

@ -13,7 +13,7 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { createHiddenTypeVariants, setupServer } from '@kbn/core-test-helpers-test-utils';
import {
registerUpdateRoute,
type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,12 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const testTypes = [
{ name: 'index-pattern', hide: false },
{ name: 'hidden-type', hide: true },
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
];
describe('PUT /api/saved_objects/{type}/{id?}', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
@ -43,6 +49,12 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.update.mockResolvedValue(clientResponse);
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -101,4 +113,14 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
{ version: 'foo' }
);
});
it('returns with status 400 for types hidden from the HTTP APIs', async () => {
const result = await supertest(httpSetup.server.listener)
.put('/api/saved_objects/hidden-from-http/hiddenId')
.send({
attributes: { title: 'does not matter' },
})
.expect(400);
expect(result.body.message).toContain("Unsupported saved object type: 'hidden-from-http'");
});
});

View file

@ -0,0 +1,27 @@
{
"type": "doc",
"value": {
"id": "test-hidden-from-http-apis-importable-exportable:hidden-from-http-apis-1",
"index": ".kibana_1",
"source": {
"test-hidden-from-http-apis-importable-exportable": {
"title": "I am hidden from http apis but the client can still see me"
},
"type": "test-hidden-from-http-apis-importable-exportable"
}
}
}
{
"type": "doc",
"value": {
"id": "test-not-hidden-from-http-apis-importable-exportable:not-hidden-from-http-apis-1",
"index": ".kibana_1",
"source": {
"test-not-hidden-from-http-apis-importable-exportable": {
"title": "I am not hidden from http apis"
},
"type": "test-not-hidden-from-http-apis-importable-exportable"
}
}
}

View file

@ -0,0 +1,39 @@
{
"attributes": {
"title": "I am hidden from http apis but the client can still see me"
},
"id": "hidden-from-http-apis-1",
"references": [],
"type": "test-hidden-from-http-apis-importable-exportable",
"version": "WzQ0LDFd"
}
{
"attributes": {
"title": "I am not hidden from http apis"
},
"id": "not-hidden-from-http-apis-1",
"references": [],
"type": "test-not-hidden-from-http-apis-importable-exportable",
"version": "WzQ5LDFd"
}
{
"attributes": {
"title": "I am also hidden from http apis but the client can still see me"
},
"id": "hidden-from-http-apis-2",
"references": [],
"type": "test-hidden-from-http-apis-importable-exportable",
"version": "WzcyLDFd"
}
{
"attributes": {
"title": "I am also not hidden from http apis"
},
"id": "not-hidden-from-http-apis-2",
"references": [],
"type": "test-not-hidden-from-http-apis-importable-exportable",
"version": "WzczLDFd"
}

View file

@ -0,0 +1,12 @@
{
"id": "savedObjectsHiddenFromHttpApisType",
"owner": {
"name": "Core",
"githubTeam": "kibana-core"
},
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["saved_objects_hidden_from_http_apis_type"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,14 @@
{
"name": "saved_objects_hidden_from_http_apis_type",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/saved_objects_hidden_from_http_apis_type",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../../node_modules/.bin/tsc"
}
}

View file

@ -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
* 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 { SavedObjectsHiddenFromHttpApisTypePlugin } from './plugin';
export const plugin = () => new SavedObjectsHiddenFromHttpApisTypePlugin();

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 { Plugin, CoreSetup } from '@kbn/core/server';
export class SavedObjectsHiddenFromHttpApisTypePlugin implements Plugin {
public setup({ savedObjects }: CoreSetup, deps: {}) {
// example of a SO type that not hidden but is hidden from the http apis.
savedObjects.registerType({
name: 'test-hidden-from-http-apis-importable-exportable',
hidden: false,
hiddenFromHttpApis: true,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
},
},
management: {
importableAndExportable: true,
visibleInManagement: true,
},
});
savedObjects.registerType({
name: 'test-not-hidden-from-http-apis-importable-exportable',
hidden: false,
hiddenFromHttpApis: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
},
},
management: {
importableAndExportable: true,
visibleInManagement: true,
},
});
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"server/**/*.ts",
"../../../../typings/**/*",
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core"
]
}

View file

@ -0,0 +1,2 @@
{"attributes": {"title": "I am hidden from http apis but the client can still see me"},"id": "hidden-from-http-apis-import1","references": [],"type":"test-hidden-from-http-apis-importable-exportable","version": 1}
{"attributes": {"title": "I am not hidden from http apis"},"id": "not-hidden-from-http-apis-import1","references": [],"type": "test-not-hidden-from-http-apis-importable-exportable","version": 1}

View file

@ -0,0 +1,209 @@
/*
* 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 { join } from 'path';
import expect from '@kbn/expect';
import type { Response } from 'supertest';
import { SavedObject } from '@kbn/core/types';
import type { PluginFunctionalProviderContext } from '../../services';
function parseNdJson(input: string): Array<SavedObject<any>> {
return input.split('\n').map((str) => JSON.parse(str));
}
export default function ({ getService }: PluginFunctionalProviderContext) {
const supertest = getService('supertest');
const kbnServer = getService('kibanaServer');
const esArchiver = getService('esArchiver');
describe('types with `hiddenFromHttpApis` ', () => {
before(async () => {
// `kbnServer.savedObjects.cleanStandardList` uses global saved objects `delete`.
// If there are any remaining saved objects registered as `hiddenFromHttpApis:true`,
// cleaning them up will fail.
await kbnServer.savedObjects.cleanStandardList();
await kbnServer.importExport.load(
'test/functional/fixtures/kbn_archiver/saved_objects_management/hidden_from_http_apis.json'
);
});
after(async () => {
// We cannot use `kbnServer.importExport.unload` to clean up test fixtures.
// `kbnServer.importExport.unload` uses the global SOM `delete` HTTP API
// and will throw on `hiddenFromHttpApis:true` objects
await esArchiver.unload(
'test/functional/fixtures/es_archiver/saved_objects_management/hidden_from_http_apis'
);
});
describe('APIS', () => {
const hiddenFromHttpApisType = {
type: 'test-hidden-from-http-apis-importable-exportable',
id: 'hidden-from-http-apis-1',
};
const notHiddenFromHttpApisType = {
type: 'test-not-hidden-from-http-apis-importable-exportable',
id: 'not-hidden-from-http-apis-1',
};
describe('_bulk_get', () => {
describe('saved objects with hiddenFromHttpApis type', () => {
const URL = '/api/kibana/management/saved_objects/_bulk_get';
it('should return 200 for types that are not hidden from the http apis', async () =>
await supertest
.post(URL)
.send([notHiddenFromHttpApisType])
.set('kbn-xsrf', 'true')
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(1);
const { type, id, meta, error } = response.body[0];
expect(type).to.eql(notHiddenFromHttpApisType.type);
expect(id).to.eql(notHiddenFromHttpApisType.id);
expect(meta).to.not.equal(undefined);
expect(error).to.equal(undefined);
}));
it('should return 200 for types that are hidden from the http apis', async () =>
await supertest
.post(URL)
.send([hiddenFromHttpApisType])
.set('kbn-xsrf', 'true')
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(1);
const { type, id, meta, error } = response.body[0];
expect(type).to.eql(hiddenFromHttpApisType.type);
expect(id).to.eql(hiddenFromHttpApisType.id);
expect(meta).to.not.equal(undefined);
expect(error).to.equal(undefined);
}));
it('should return 200 for a mix of types', async () =>
await supertest
.post(URL)
.send([hiddenFromHttpApisType, notHiddenFromHttpApisType])
.set('kbn-xsrf', 'true')
.expect(200)
.expect(200)
.then((response: Response) => {
expect(response.body).to.have.length(2);
const { type, id, meta, error } = response.body[0];
expect(type).to.eql(hiddenFromHttpApisType.type);
expect(id).to.eql(hiddenFromHttpApisType.id);
expect(meta).to.not.equal(undefined);
expect(error).to.equal(undefined);
}));
});
});
describe('find', () => {
it('returns saved objects registered as hidden from the http Apis', async () => {
await supertest
.get(
`/api/kibana/management/saved_objects/_find?type=${hiddenFromHttpApisType.type}&fields=title`
)
.set('kbn-xsrf', 'true')
.expect(200)
.then((resp) => {
expect(
resp.body.saved_objects.map((so: { id: string; type: string }) => ({
id: so.id,
type: so.type,
}))
).to.eql([
{
id: 'hidden-from-http-apis-1',
type: 'test-hidden-from-http-apis-importable-exportable',
},
{
id: 'hidden-from-http-apis-2',
type: 'test-hidden-from-http-apis-importable-exportable',
},
]);
});
});
});
describe('export', () => {
it('allows to export them directly by id', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-hidden-from-http-apis-importable-exportable',
id: 'hidden-from-http-apis-1',
},
],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql(['hidden-from-http-apis-1']);
});
});
it('allows to export them directly by type', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-hidden-from-http-apis-importable-exportable'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql([
'hidden-from-http-apis-1',
'hidden-from-http-apis-2',
]);
});
});
});
describe('import', () => {
it('allows to import them', async () => {
await supertest
.post('/api/saved_objects/_import')
.set('kbn-xsrf', 'true')
.attach('file', join(__dirname, './exports/_import_hidden_from_http_apis.ndjson'))
.expect(200)
.then((resp) => {
expect(resp.body).to.eql({
success: true,
successCount: 2,
successResults: [
{
id: 'hidden-from-http-apis-import1',
meta: {
title: 'I am hidden from http apis but the client can still see me',
},
type: 'test-hidden-from-http-apis-importable-exportable',
},
{
id: 'not-hidden-from-http-apis-import1',
meta: {
title: 'I am not hidden from http apis',
},
type: 'test-not-hidden-from-http-apis-importable-exportable',
},
],
warnings: [],
});
});
});
});
});
});
}

View file

@ -17,5 +17,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
loadTestFile(require.resolve('./import_warnings'));
loadTestFile(require.resolve('./hidden_types'));
loadTestFile(require.resolve('./visible_in_management'));
loadTestFile(require.resolve('./hidden_from_http_apis'));
});
}

View file

@ -18,7 +18,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
async function getSavedObjectCounters() {
// wait until ES indexes the counter SavedObject;
await new Promise((res) => setTimeout(res, 7 * 1000));
await new Promise((res) => setTimeout(res, 10 * 1000));
return await supertest
.get('/api/saved_objects/_find?type=usage-counters')

View file

@ -958,6 +958,8 @@
"@kbn/saved-object-import-warnings-plugin/*": ["test/plugin_functional/plugins/saved_object_import_warnings/*"],
"@kbn/saved-objects-finder-plugin": ["src/plugins/saved_objects_finder"],
"@kbn/saved-objects-finder-plugin/*": ["src/plugins/saved_objects_finder/*"],
"@kbn/saved-objects-hidden-from-http-apis-type-plugin": ["test/plugin_functional/plugins/saved_objects_hidden_from_http_apis_type"],
"@kbn/saved-objects-hidden-from-http-apis-type-plugin/*": ["test/plugin_functional/plugins/saved_objects_hidden_from_http_apis_type/*"],
"@kbn/saved-objects-hidden-type-plugin": ["test/plugin_functional/plugins/saved_objects_hidden_type"],
"@kbn/saved-objects-hidden-type-plugin/*": ["test/plugin_functional/plugins/saved_objects_hidden_type/*"],
"@kbn/saved-objects-management-plugin": ["src/plugins/saved_objects_management"],