[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 ### 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"/>. 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 ==== Type visibility
It is recommended that plugins only expose Saved Object types that are necessary. It is recommended that plugins only expose Saved Object types that are necessary.
That is so to provide better backward compatibility. 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. 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. 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 === Client side usage
==== References ==== References

View file

@ -139,6 +139,67 @@ describe('SavedObjectTypeRegistry', () => {
}).not.toThrow(); }).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' // 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', () => { describe('#getIndex', () => {
it('returns correct value for the type', () => { it('returns correct value for the type', () => {
registry.registerType(createType({ name: 'typeA', indexPattern: '.custom-index' })); 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)); 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} */ /** {@inheritDoc ISavedObjectTypeRegistry.getAllTypes} */
public getAllTypes() { public getAllTypes() {
return [...this.types.values()]; return [...this.types.values()];
@ -78,6 +83,11 @@ export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry {
return this.types.get(type)?.hidden ?? false; return this.types.get(type)?.hidden ?? false;
} }
/** {@inheritDoc ISavedObjectTypeRegistry.isHiddenFromHttpApi} */
public isHiddenFromHttpApis(type: string) {
return !!this.types.get(type)?.hiddenFromHttpApis;
}
/** {@inheritDoc ISavedObjectTypeRegistry.getType} */ /** {@inheritDoc ISavedObjectTypeRegistry.getType} */
public getIndex(type: string) { public getIndex(type: string) {
return this.types.get(type)?.indexPattern; 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) {
if (management.onExport && !management.importableAndExportable) { if (management.onExport && !management.importableAndExportable) {
throw new Error( 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(), registerType: jest.fn(),
getType: jest.fn(), getType: jest.fn(),
getVisibleTypes: jest.fn(), getVisibleTypes: jest.fn(),
getVisibleToHttpApisTypes: jest.fn(),
getAllTypes: jest.fn(), getAllTypes: jest.fn(),
getImportableAndExportableTypes: jest.fn(), getImportableAndExportableTypes: jest.fn(),
isNamespaceAgnostic: jest.fn(), isNamespaceAgnostic: jest.fn(),
@ -23,6 +24,7 @@ const createRegistryMock = (): jest.Mocked<
isMultiNamespace: jest.fn(), isMultiNamespace: jest.fn(),
isShareable: jest.fn(), isShareable: jest.fn(),
isHidden: jest.fn(), isHidden: jest.fn(),
isHiddenFromHttpApis: jest.fn(),
getIndex: jest.fn(), getIndex: jest.fn(),
isImportableAndExportable: jest.fn(), isImportableAndExportable: jest.fn(),
}; };
@ -33,6 +35,7 @@ const createRegistryMock = (): jest.Mocked<
mock.getIndex.mockReturnValue('.kibana-test'); mock.getIndex.mockReturnValue('.kibana-test');
mock.getIndex.mockReturnValue('.kibana-test'); mock.getIndex.mockReturnValue('.kibana-test');
mock.isHidden.mockReturnValue(false); mock.isHidden.mockReturnValue(false);
mock.isHiddenFromHttpApis.mockReturnValue(false);
mock.isNamespaceAgnostic.mockImplementation((type: string) => type === 'global'); mock.isNamespaceAgnostic.mockImplementation((type: string) => type === 'global');
mock.isSingleNamespace.mockImplementation( mock.isSingleNamespace.mockImplementation(
(type: string) => type !== 'global' && type !== 'shared' (type: string) => type !== 'global' && type !== 'shared'
@ -40,6 +43,7 @@ const createRegistryMock = (): jest.Mocked<
mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared');
mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isShareable.mockImplementation((type: string) => type === 'shared');
mock.isImportableAndExportable.mockReturnValue(true); mock.isImportableAndExportable.mockReturnValue(true);
mock.getVisibleToHttpApisTypes.mockReturnValue(false);
return mock; return mock;
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@
import { schema } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal'; import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types'; import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils'; import { catchAndReturnBoomErrors, throwOnHttpHiddenTypes } from './utils';
interface RouteDependencies { interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup; coreUsageData: InternalCoreUsageDataSetup;
@ -67,7 +67,7 @@ export const registerFindRoute = (
const usageStatsClient = coreUsageData.getClient(); const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsFind({ request: req }).catch(() => {}); usageStatsClient.incrementSavedObjectsFind({ request: req }).catch(() => {});
// manually validation to avoid using JSON.parse twice // manually validate to avoid using JSON.parse twice
let aggs; let aggs;
if (query.aggs) { if (query.aggs) {
try { try {
@ -81,10 +81,25 @@ export const registerFindRoute = (
} }
} }
const { savedObjects } = await context.core; 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({ const result = await savedObjects.client.find({
perPage: query.per_page, perPage: query.per_page,
page: query.page, page: query.page,
type: Array.isArray(query.type) ? query.type : [query.type], type: findForTypes,
search: query.search, search: query.search,
defaultSearchOperator: query.default_search_operator, defaultSearchOperator: query.default_search_operator,
searchFields: searchFields:

View file

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

View file

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

View file

@ -6,7 +6,15 @@
* Side Public License, v 1. * 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 { Readable } from 'stream';
import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils';
import { catchAndReturnBoomErrors } from './utils'; import { catchAndReturnBoomErrors } from './utils';
@ -18,6 +26,7 @@ import type {
KibanaResponseFactory, KibanaResponseFactory,
} from '@kbn/core-http-server'; } from '@kbn/core-http-server';
import { kibanaResponseFactory } from '@kbn/core-http-router-server-internal'; import { kibanaResponseFactory } from '@kbn/core-http-router-server-internal';
import { typeRegistryInstanceMock } from '../saved_objects_service.test.mocks';
async function readStreamToCompletion(stream: Readable) { async function readStreamToCompletion(stream: Readable) {
return createPromiseFromStreams([stream, createConcatStream([])]); 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 Boom from '@hapi/boom';
import type { RequestHandlerWrapper } from '@kbn/core-http-server'; import type { RequestHandlerWrapper } from '@kbn/core-http-server';
import type { SavedObject } from '@kbn/core-saved-objects-common'; 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) { export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) {
const savedObjects = await createPromiseFromStreams([ 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/core-elasticsearch-server-mocks",
"@kbn/utils", "@kbn/utils",
"@kbn/core-http-router-server-internal", "@kbn/core-http-router-server-internal",
"@kbn/core-saved-objects-utils-server",
], ],
"exclude": [ "exclude": [
"target/**/*", "target/**/*",

View file

@ -33,6 +33,14 @@ export interface SavedObjectsType<Attributes = any> {
* See {@link SavedObjectsServiceStart.createInternalRepository | createInternalRepository}. * See {@link SavedObjectsServiceStart.createInternalRepository | createInternalRepository}.
*/ */
hidden: boolean; 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. * The {@link SavedObjectsNamespaceType | namespace type} for the type.
*/ */

View file

@ -24,6 +24,13 @@ export interface ISavedObjectTypeRegistry {
*/ */
getVisibleTypes(): SavedObjectsType[]; 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. * Return all {@link SavedObjectsType | types} currently registered, including the hidden ones.
* *
@ -66,6 +73,11 @@ export interface ISavedObjectTypeRegistry {
*/ */
isHidden(type: string): boolean; 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 * Returns the `indexPattern` property for given type, or `undefined` if
* the type is not registered. * the type is not registered.

View file

@ -8,3 +8,4 @@
export { setupServer } from './src/setup_server'; export { setupServer } from './src/setup_server';
export { createExportableType } from './src/create_exportable_type'; 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, registerBulkCreateRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal'; } 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>>; 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', () => { describe('POST /api/saved_objects/_bulk_create', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -34,6 +39,12 @@ describe('POST /api/saved_objects/_bulk_create', () => {
savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: [] }); 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 = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -131,4 +142,23 @@ describe('POST /api/saved_objects/_bulk_create', () => {
const args = savedObjectsClient.bulkCreate.mock.calls[0]; const args = savedObjectsClient.bulkCreate.mock.calls[0];
expect(args[1]).toEqual({ overwrite: true }); 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerBulkDeleteRoute, registerBulkDeleteRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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', () => { describe('POST /api/saved_objects/_bulk_delete', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -35,6 +40,13 @@ describe('POST /api/saved_objects/_bulk_delete', () => {
savedObjectsClient.bulkDelete.mockResolvedValue({ savedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [], statuses: [],
}); });
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -94,4 +106,31 @@ describe('POST /api/saved_objects/_bulk_delete', () => {
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith(docs, { force: true }); 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerBulkGetRoute, registerBulkGetRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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', () => { describe('POST /api/saved_objects/_bulk_get', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -35,6 +40,12 @@ describe('POST /api/saved_objects/_bulk_get', () => {
savedObjectsClient.bulkGet.mockResolvedValue({ savedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [], saved_objects: [],
}); });
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -96,4 +107,17 @@ describe('POST /api/saved_objects/_bulk_get', () => {
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(docs); 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerBulkResolveRoute, registerBulkResolveRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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', () => { describe('POST /api/saved_objects/_bulk_resolve', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -35,6 +40,13 @@ describe('POST /api/saved_objects/_bulk_resolve', () => {
savedObjectsClient.bulkResolve.mockResolvedValue({ savedObjectsClient.bulkResolve.mockResolvedValue({
resolved_objects: [], resolved_objects: [],
}); });
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -99,4 +111,17 @@ describe('POST /api/saved_objects/_bulk_resolve', () => {
expect(savedObjectsClient.bulkResolve).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkResolve).toHaveBeenCalledWith(docs); 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerBulkUpdateRoute, registerBulkUpdateRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,13 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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', () => { describe('PUT /api/saved_objects/_bulk_update', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -30,8 +37,15 @@ describe('PUT /api/saved_objects/_bulk_update', () => {
beforeEach(async () => { beforeEach(async () => {
({ server, httpSetup, handlerContext } = await setupServer()); ({ server, httpSetup, handlerContext } = await setupServer());
savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient = handlerContext.savedObjects.client;
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create(); 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerCreateRoute, registerCreateRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,11 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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}', () => { describe('POST /api/saved_objects/{type}', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -49,6 +54,12 @@ describe('POST /api/saved_objects/{type}', () => {
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerCreateRoute(router, { coreUsageData }); registerCreateRoute(router, { coreUsageData });
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
await server.start(); await server.start();
}); });
@ -121,4 +132,17 @@ describe('POST /api/saved_objects/{type}', () => {
{ overwrite: false, id: 'logstash-*' }, { 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerDeleteRoute, registerDeleteRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,12 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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}', () => { describe('DELETE /api/saved_objects/{type}/{id}', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -32,6 +38,11 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => {
({ server, httpSetup, handlerContext } = await setupServer()); ({ server, httpSetup, handlerContext } = await setupServer());
savedObjectsClient = handlerContext.savedObjects.getClient(); savedObjectsClient = handlerContext.savedObjects.getClient();
handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient); 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 = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
@ -78,4 +89,19 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => {
force: true, 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerFindRoute, registerFindRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -23,6 +23,15 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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', () => { describe('GET /api/saved_objects/_find', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -39,6 +48,13 @@ describe('GET /api/saved_objects/_find', () => {
beforeEach(async () => { beforeEach(async () => {
({ server, httpSetup, handlerContext } = await setupServer()); ({ 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 = handlerContext.savedObjects.client;
savedObjectsClient.find.mockResolvedValue(clientResponse); 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 () => { it('formats successful response and records usage stats', async () => {
const findResponse = { const findResponse = {
total: 2, total: 2,

View file

@ -22,9 +22,16 @@ import {
registerGetRoute, registerGetRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal'; } from '@kbn/core-saved-objects-server-internal';
import { createHiddenTypeVariants } from '@kbn/core-test-helpers-test-utils';
const coreId = Symbol('core'); 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}', () => { describe('GET /api/saved_objects/{type}/{id}', () => {
let server: HttpService; let server: HttpService;
let httpSetup: InternalHttpServiceSetup; let httpSetup: InternalHttpServiceSetup;
@ -44,6 +51,12 @@ describe('GET /api/saved_objects/{type}/{id}', () => {
}); });
handlerContext = coreMock.createRequestHandlerContext(); 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; savedObjectsClient = handlerContext.savedObjects.client;
httpSetup.registerRouteHandlerContext<InternalSavedObjectsRequestHandlerContext, 'core'>( httpSetup.registerRouteHandlerContext<InternalSavedObjectsRequestHandlerContext, 'core'>(
@ -101,4 +114,11 @@ describe('GET /api/saved_objects/{type}/{id}', () => {
const args = savedObjectsClient.get.mock.calls[0]; const args = savedObjectsClient.get.mock.calls[0];
expect(args).toEqual(['index-pattern', 'logstash-*']); 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, registerResolveRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal'; } from '@kbn/core-saved-objects-server-internal';
import { createHiddenTypeVariants } from '@kbn/core-test-helpers-test-utils';
const coreId = Symbol('core'); 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}', () => { describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
let server: HttpService; let server: HttpService;
let httpSetup: InternalHttpServiceSetup; let httpSetup: InternalHttpServiceSetup;
@ -44,6 +51,13 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
}); });
handlerContext = coreMock.createRequestHandlerContext(); 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; savedObjectsClient = handlerContext.savedObjects.client;
httpSetup.registerRouteHandlerContext<InternalSavedObjectsRequestHandlerContext, 'core'>( httpSetup.registerRouteHandlerContext<InternalSavedObjectsRequestHandlerContext, 'core'>(
@ -101,4 +115,11 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
const args = savedObjectsClient.resolve.mock.calls[0]; const args = savedObjectsClient.resolve.mock.calls[0];
expect(args).toEqual(['index-pattern', 'logstash-*']); 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, coreUsageStatsClientMock,
coreUsageDataServiceMock, coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks'; } 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 { import {
registerUpdateRoute, registerUpdateRoute,
type InternalSavedObjectsRequestHandlerContext, type InternalSavedObjectsRequestHandlerContext,
@ -21,6 +21,12 @@ import {
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>; 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?}', () => { describe('PUT /api/saved_objects/{type}/{id?}', () => {
let server: SetupServerReturn['server']; let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup']; let httpSetup: SetupServerReturn['httpSetup'];
@ -43,6 +49,12 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.update.mockResolvedValue(clientResponse); savedObjectsClient.update.mockResolvedValue(clientResponse);
handlerContext.savedObjects.typeRegistry.getType.mockImplementation((typename: string) => {
return testTypes
.map((typeDesc) => createHiddenTypeVariants(typeDesc))
.find((fullTest) => fullTest.name === typename);
});
const router = const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/'); httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient = coreUsageStatsClientMock.create();
@ -101,4 +113,14 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
{ version: 'foo' } { 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('./import_warnings'));
loadTestFile(require.resolve('./hidden_types')); loadTestFile(require.resolve('./hidden_types'));
loadTestFile(require.resolve('./visible_in_management')); 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() { async function getSavedObjectCounters() {
// wait until ES indexes the counter SavedObject; // 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 return await supertest
.get('/api/saved_objects/_find?type=usage-counters') .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-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-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-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"], "@kbn/saved-objects-management-plugin": ["src/plugins/saved_objects_management"],