[7.x] Spaces - server-side to NP plugin (#46181) (#48222)

This commit is contained in:
Larry Gregory 2019-10-15 11:15:41 -04:00 committed by GitHub
parent e5f46b88f7
commit 30f69df238
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 2759 additions and 2347 deletions

View file

@ -35,7 +35,7 @@
"xpack.security": "legacy/plugins/security",
"xpack.server": "legacy/server",
"xpack.snapshotRestore": "legacy/plugins/snapshot_restore",
"xpack.spaces": "legacy/plugins/spaces",
"xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"],
"xpack.transform": "legacy/plugins/transform",
"xpack.upgradeAssistant": "legacy/plugins/upgrade_assistant",
"xpack.uptime": "legacy/plugins/uptime",

View file

@ -6,7 +6,7 @@
import Hapi from 'hapi';
import { EncryptedSavedObjectsStartContract } from '../shim';
import { SpacesPlugin as SpacesPluginStartContract } from '../../../spaces';
import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../../spaces';
import { Logger } from '../../../../../../src/core/server';
import { validateParams, validateConfig, validateSecrets } from './validate_with_schema';
import {

View file

@ -11,7 +11,7 @@ import { ActionsConfigType } from './types';
import { TaskManager } from '../../task_manager';
import { XPackMainPlugin } from '../../xpack_main/xpack_main';
import KbnServer from '../../../../../src/legacy/server/kbn_server';
import { SpacesPlugin as SpacesPluginStartContract } from '../../spaces';
import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces';
import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects';
import { PluginSetupContract as SecurityPlugin } from '../../../../plugins/security/server';
import {

View file

@ -6,7 +6,7 @@
import Hapi from 'hapi';
import { Legacy } from 'kibana';
import { SpacesPlugin as SpacesPluginStartContract } from '../../spaces';
import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces';
import { TaskManager } from '../../task_manager';
import { XPackMainPlugin } from '../../xpack_main/xpack_main';
import KbnServer from '../../../../../src/legacy/server/kbn_server';

View file

@ -5,7 +5,7 @@
*/
import { Request } from 'hapi';
import { SpacesPlugin } from '../../../spaces';
import { LegacySpacesPlugin } from '../../../spaces';
import { Space } from '../../../spaces/common/model/space';
interface GetActiveSpaceResponse {
@ -13,7 +13,7 @@ interface GetActiveSpaceResponse {
space?: Space;
}
export function spacesUtilsProvider(spacesPlugin: SpacesPlugin, request: Request) {
export function spacesUtilsProvider(spacesPlugin: LegacySpacesPlugin, request: Request) {
async function activeSpace(): Promise<GetActiveSpaceResponse> {
try {
return {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SpacesPlugin } from '../../../../spaces';
import { LegacySpacesPlugin } from '../../../../spaces';
import { OptionalPlugin } from '../../../../../server/lib/optional_plugin';
import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically';
@ -23,7 +23,7 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => {
getBasePath: jest.fn(),
getScopedSpacesClient: jest.fn(),
getActiveSpace: jest.fn(),
} as OptionalPlugin<SpacesPlugin>;
} as OptionalPlugin<LegacySpacesPlugin>;
const request = Symbol();
const privilegeOrPrivileges = ['foo', 'bar'];
const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory(
@ -45,7 +45,7 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => {
const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges);
const mockSpaces = {
isEnabled: false,
} as OptionalPlugin<SpacesPlugin>;
} as OptionalPlugin<LegacySpacesPlugin>;
const request = Symbol();
const privilegeOrPrivileges = ['foo', 'bar'];
const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory(

View file

@ -13,7 +13,7 @@ import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from '.
* you may not use this file except in compliance with the Elastic License.
*/
import { SpacesPlugin } from '../../../../spaces';
import { LegacySpacesPlugin } from '../../../../spaces';
import { OptionalPlugin } from '../../../../../server/lib/optional_plugin';
export type CheckPrivilegesDynamically = (
@ -26,7 +26,7 @@ export type CheckPrivilegesDynamicallyWithRequest = (
export function checkPrivilegesDynamicallyWithRequestFactory(
checkPrivilegesWithRequest: CheckPrivilegesWithRequest,
spaces: OptionalPlugin<SpacesPlugin>
spaces: OptionalPlugin<LegacySpacesPlugin>
): CheckPrivilegesDynamicallyWithRequest {
return function checkPrivilegesDynamicallyWithRequest(request: Legacy.Request) {
const checkPrivileges = checkPrivilegesWithRequest(request);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SpacesPlugin } from '../../../../spaces';
import { LegacySpacesPlugin } from '../../../../spaces';
import { OptionalPlugin } from '../../../../../server/lib/optional_plugin';
import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges';
@ -19,7 +19,7 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => {
const mockSpaces = ({
isEnabled: true,
namespaceToSpaceId: jest.fn().mockReturnValue(spaceId),
} as unknown) as OptionalPlugin<SpacesPlugin>;
} as unknown) as OptionalPlugin<LegacySpacesPlugin>;
const request = Symbol();
const privilegeOrPrivileges = ['foo', 'bar'];
@ -50,7 +50,7 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => {
namespaceToSpaceId: jest.fn().mockImplementation(() => {
throw new Error('should not be called');
}),
} as unknown) as OptionalPlugin<SpacesPlugin>;
} as unknown) as OptionalPlugin<LegacySpacesPlugin>;
const request = Symbol();

View file

@ -5,7 +5,7 @@
*/
import { Legacy } from 'kibana';
import { SpacesPlugin } from '../../../../spaces';
import { LegacySpacesPlugin } from '../../../../spaces';
import { OptionalPlugin } from '../../../../../server/lib/optional_plugin';
import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges';
@ -19,7 +19,7 @@ export type CheckSavedObjectsPrivileges = (
export const checkSavedObjectsPrivilegesWithRequestFactory = (
checkPrivilegesWithRequest: CheckPrivilegesWithRequest,
spaces: OptionalPlugin<SpacesPlugin>
spaces: OptionalPlugin<LegacySpacesPlugin>
): CheckSavedObjectsPrivilegesWithRequest => {
return function checkSavedObjectsPrivilegesWithRequest(request: Legacy.Request) {
return async function checkSavedObjectsPrivileges(

View file

@ -7,7 +7,7 @@
import { Server } from 'hapi';
import { getClient } from '../../../../../server/lib/get_client_shield';
import { SpacesPlugin } from '../../../../spaces';
import { LegacySpacesPlugin } from '../../../../spaces';
import { XPackFeature, XPackMainPlugin } from '../../../../xpack_main/xpack_main';
import { APPLICATION_PREFIX } from '../../../common/constants';
import { OptionalPlugin } from '../../../../../server/lib/optional_plugin';
@ -38,7 +38,7 @@ export function createAuthorizationService(
server: Server,
securityXPackFeature: XPackFeature,
xpackMainPlugin: XPackMainPlugin,
spaces: OptionalPlugin<SpacesPlugin>
spaces: OptionalPlugin<LegacySpacesPlugin>
): AuthorizationService {
const shieldClient = getClient(server);
const config = server.config();

View file

@ -6,5 +6,3 @@
export { isReservedSpace } from './is_reserved_space';
export { MAX_SPACE_INITIALS } from './constants';
export { getSpaceIdFromPath, addSpaceIdToPath } from './lib/spaces_url_parser';

View file

@ -4,29 +4,29 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
import { resolve } from 'path';
import KbnServer, { Server } from 'src/legacy/server/kbn_server';
import { CoreSetup, PluginInitializerContext } from 'src/core/server';
import { Legacy } from 'kibana';
import { KibanaRequest } from '../../../../src/core/server';
import { SpacesServiceSetup } from '../../../plugins/spaces/server/spaces_service/spaces_service';
import { SpacesPluginSetup } from '../../../plugins/spaces/server';
import { createOptionalPlugin } from '../../server/lib/optional_plugin';
// @ts-ignore
import { AuditLogger } from '../../server/lib/audit_logger';
import mappings from './mappings.json';
import { wrapError } from './server/lib/errors';
import { getActiveSpace } from './server/lib/get_active_space';
import { migrateToKibana660 } from './server/lib/migrations';
import { plugin } from './server/new_platform';
import { SecurityPlugin } from '../security';
import { SpacesServiceSetup } from './server/new_platform/spaces_service/spaces_service';
// @ts-ignore
import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize';
import { initSpaceSelectorView, initEnterSpaceView } from './server/routes/views';
export interface SpacesPlugin {
getSpaceId: SpacesServiceSetup['getSpaceId'];
getActiveSpace: SpacesServiceSetup['getActiveSpace'];
export interface LegacySpacesPlugin {
getSpaceId: (request: Legacy.Request) => ReturnType<SpacesServiceSetup['getSpaceId']>;
getActiveSpace: (request: Legacy.Request) => ReturnType<SpacesServiceSetup['getActiveSpace']>;
spaceIdToNamespace: SpacesServiceSetup['spaceIdToNamespace'];
namespaceToSpaceId: SpacesServiceSetup['namespaceToSpaceId'];
getBasePath: SpacesServiceSetup['getBasePath'];
getScopedSpacesClient: SpacesServiceSetup['scopedClient'];
}
export const spaces = (kibana: Record<string, any>) =>
@ -36,13 +36,6 @@ export const spaces = (kibana: Record<string, any>) =>
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch', 'xpack_main'],
config(Joi: any) {
return Joi.object({
enabled: Joi.boolean().default(true),
maxSpaces: Joi.number().default(1000),
}).default();
},
uiCapabilities() {
return {
spaces: {
@ -92,18 +85,20 @@ export const spaces = (kibana: Record<string, any>) =>
},
async replaceInjectedVars(
vars: Record<string, any>,
request: Record<string, any>,
request: Legacy.Request,
server: Record<string, any>
) {
const spacesClient = await server.plugins.spaces.getScopedSpacesClient(request);
const spacesPlugin = server.newPlatform.setup.plugins.spaces as SpacesPluginSetup;
if (!spacesPlugin) {
throw new Error('New Platform XPack Spaces plugin is not available.');
}
const kibanaRequest = KibanaRequest.from(request);
const spaceId = spacesPlugin.spacesService.getSpaceId(kibanaRequest);
const spacesClient = await spacesPlugin.spacesService.scopedClient(kibanaRequest);
try {
vars.activeSpace = {
valid: true,
space: await getActiveSpace(
spacesClient,
request.getBasePath(),
server.config().get('server.basePath')
),
space: await spacesClient.get(spaceId),
};
} catch (e) {
vars.activeSpace = {
@ -118,52 +113,18 @@ export const spaces = (kibana: Record<string, any>) =>
async init(server: Server) {
const kbnServer = (server as unknown) as KbnServer;
const initializerContext = {
config: {
create: () => {
return Rx.of({
maxSpaces: server.config().get('xpack.spaces.maxSpaces'),
});
},
},
logger: {
get(...contextParts: string[]) {
return kbnServer.newPlatform.coreContext.logger.get(
'plugins',
'spaces',
...contextParts
);
},
},
} as PluginInitializerContext;
const core = (kbnServer.newPlatform.setup.core as unknown) as CoreSetup;
const plugins = {
xpackMain: server.plugins.xpack_main,
// TODO: Spaces has a circular dependency with Security right now.
// Security is not yet available when init runs, so this is wrapped in an optional function for the time being.
security: createOptionalPlugin<SecurityPlugin>(
server.config(),
'xpack.security',
server.plugins,
'security'
),
spaces: this,
};
const { spacesService, registerLegacyAPI } = await plugin(initializerContext).setup(
core,
plugins
);
const spacesPlugin = kbnServer.newPlatform.setup.plugins.spaces as SpacesPluginSetup;
if (!spacesPlugin) {
throw new Error('New Platform XPack Spaces plugin is not available.');
}
const config = server.config();
const { registerLegacyAPI, createDefaultSpace } = spacesPlugin.__legacyCompat;
registerLegacyAPI({
router: server.route.bind(server),
legacyConfig: {
serverBasePath: config.get('server.basePath'),
serverDefaultRoute: config.get('server.defaultRoute'),
kibanaIndex: config.get('kibana.index'),
},
savedObjects: server.savedObjects,
@ -178,16 +139,30 @@ export const spaces = (kibana: Record<string, any>) =>
create: (pluginId: string) =>
new AuditLogger(server, pluginId, server.config(), server.plugins.xpack_main.info),
},
security: createOptionalPlugin<SecurityPlugin>(
server.config(),
'xpack.security',
server.plugins,
'security'
),
xpackMain: server.plugins.xpack_main,
});
initEnterSpaceView(server);
initSpaceSelectorView(server);
server.expose('getSpaceId', (request: any) => spacesService.getSpaceId(request));
server.expose('getActiveSpace', spacesService.getActiveSpace);
server.expose('spaceIdToNamespace', spacesService.spaceIdToNamespace);
server.expose('namespaceToSpaceId', spacesService.namespaceToSpaceId);
server.expose('getBasePath', spacesService.getBasePath);
server.expose('getScopedSpacesClient', spacesService.scopedClient);
watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => {
await createDefaultSpace();
});
server.expose('getSpaceId', (request: Legacy.Request) =>
spacesPlugin.spacesService.getSpaceId(request)
);
server.expose('getActiveSpace', (request: Legacy.Request) =>
spacesPlugin.spacesService.getActiveSpace(request)
);
server.expose('spaceIdToNamespace', spacesPlugin.spacesService.spaceIdToNamespace);
server.expose('namespaceToSpaceId', spacesPlugin.spacesService.namespaceToSpaceId);
server.expose('getBasePath', spacesPlugin.spacesService.getBasePath);
},
});

View file

@ -10,7 +10,7 @@ import { Space } from '../../common/model/space';
import { GetSpacePurpose } from '../../common/model/types';
import { CopySavedObjectsToSpaceResponse } from './copy_saved_objects_to_space/types';
import { ENTER_SPACE_PATH } from '../../common/constants';
import { addSpaceIdToPath } from '../../common';
import { addSpaceIdToPath } from '../../../../../plugins/spaces/common';
export class SpacesManager extends EventEmitter {
constructor(private readonly serverBasePath: string) {

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface LicenseCheckResult {
showSpaces: boolean;
}
/**
* Returns object that defines behavior of the spaces related features based
* on the license information extracted from the xPackInfo.
* @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from.
* @returns {LicenseCheckResult}
*/
export function checkLicense(xPackInfo: any): LicenseCheckResult {
if (!xPackInfo.isAvailable()) {
return {
showSpaces: false,
};
}
const isAnyXpackLicense = xPackInfo.license.isOneOf([
'basic',
'standard',
'gold',
'platinum',
'trial',
]);
if (!isAnyXpackLicense) {
return {
showSpaces: false,
};
}
return {
showSpaces: true,
};
}

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Space } from '../../common/model/space';
import { wrapError } from './errors';
import { SpacesClient } from './spaces_client';
import { getSpaceIdFromPath } from '../../common';
export async function getActiveSpace(
spacesClient: SpacesClient,
requestBasePath: string,
serverBasePath: string
): Promise<Space> {
const spaceId = getSpaceIdFromPath(requestBasePath, serverBasePath);
try {
return spacesClient.get(spaceId);
} catch (e) {
throw wrapError(e);
}
}

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { XPackMainPlugin } from '../../../xpack_main/xpack_main';
interface LicenseCheckDeps {
xpackMain: XPackMainPlugin;
}
export function routePreCheckLicense({ xpackMain }: LicenseCheckDeps) {
const pluginId = 'spaces';
return function forbidApiAccess(request: any) {
const licenseCheckResults = xpackMain.info.feature(pluginId).getLicenseCheckResults();
if (!licenseCheckResults.showSpaces) {
return Boom.forbidden(licenseCheckResults.linksMessage);
} else {
return '';
}
};
}

View file

@ -1,167 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { spaceSchema } from './space_schema';
const defaultProperties = {
id: 'foo',
name: 'foo',
};
describe('#id', () => {
test('is optional', () => {
const result = spaceSchema.validate({
...defaultProperties,
id: undefined,
});
expect(result.error).toBeNull();
});
test('allows lowercase a-z, 0-9, "_" and "-"', () => {
const result = spaceSchema.validate({
...defaultProperties,
id: 'abcdefghijklmnopqrstuvwxyz0123456789_-',
});
expect(result.error).toBeNull();
});
test(`doesn't allow uppercase`, () => {
const result = spaceSchema.validate({
...defaultProperties,
id: 'Foo',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "id" fails because ["id" with value "Foo" fails to match the lower case, a-z, 0-9, "_", and "-" are allowed pattern]]`
);
});
test(`doesn't allow an empty string`, () => {
const result = spaceSchema.validate({
...defaultProperties,
id: '',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "id" fails because ["id" is not allowed to be empty]]`
);
});
['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', ',', '.', '/', '?'].forEach(
invalidCharacter => {
test(`doesn't allow ${invalidCharacter}`, () => {
const result = spaceSchema.validate({
...defaultProperties,
id: `foo-${invalidCharacter}`,
});
expect(result.error).toMatchObject({
message: `child "id" fails because ["id" with value "foo-${invalidCharacter}" fails to match the lower case, a-z, 0-9, "_", and "-" are allowed pattern]`,
});
});
}
);
});
describe('#color', () => {
test('is optional', () => {
const result = spaceSchema.validate({
...defaultProperties,
color: undefined,
});
expect(result.error).toBeNull();
});
test(`doesn't allow an empty string`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "color" fails because ["color" is not allowed to be empty]]`
);
});
test(`allows lower case hex color code`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '#aabbcc',
});
expect(result.error).toBeNull();
});
test(`allows upper case hex color code`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '#AABBCC',
});
expect(result.error).toBeNull();
});
test(`allows numeric hex color code`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '#123456',
});
expect(result.error).toBeNull();
});
test(`must start with a hash`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '123456',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "color" fails because ["color" with value "123456" fails to match the 6 digit hex color, starting with a # pattern]]`
);
});
test(`cannot exceed 6 digits following the hash`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '1234567',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "color" fails because ["color" with value "1234567" fails to match the 6 digit hex color, starting with a # pattern]]`
);
});
test(`cannot be fewer than 6 digits following the hash`, () => {
const result = spaceSchema.validate({
...defaultProperties,
color: '12345',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "color" fails because ["color" with value "12345" fails to match the 6 digit hex color, starting with a # pattern]]`
);
});
});
describe('#imageUrl', () => {
test('is optional', () => {
const result = spaceSchema.validate({
...defaultProperties,
imageUrl: undefined,
});
expect(result.error).toBeNull();
});
test(`must start with data:image`, () => {
const result = spaceSchema.validate({
...defaultProperties,
imageUrl: 'notValid',
});
expect(result.error).toMatchInlineSnapshot(
`[ValidationError: child "imageUrl" fails because ["imageUrl" with value "notValid" fails to match the Image URL should start with 'data:image' pattern]]`
);
});
test(`checking that a valid image is accepted as imageUrl`, () => {
const result = spaceSchema.validate({
...defaultProperties,
imageUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==',
});
expect(result.error).toBeNull();
});
});

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { MAX_SPACE_INITIALS } from '../../common/constants';
export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/;
export const spaceSchema = Joi.object({
id: Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`),
name: Joi.string().required(),
description: Joi.string().allow(''),
initials: Joi.string().max(MAX_SPACE_INITIALS),
color: Joi.string().regex(/^#[a-zA-Z0-9]{6}$/, `6 digit hex color, starting with a #`),
disabledFeatures: Joi.array()
.items(Joi.string())
.default([]),
_reserved: Joi.boolean(),
imageUrl: Joi.string()
.allow('')
.regex(/^data:image.*$/, `Image URL should start with 'data:image'`),
}).default();

View file

@ -1,309 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
import { Server } from 'hapi';
import { Legacy } from 'kibana';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchServiceMock, coreMock } from 'src/core/server/mocks';
import { SavedObjectsSchema, SavedObjectsLegacyService } from 'src/core/server';
import { Readable } from 'stream';
import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams';
import { createOptionalPlugin } from '../../../../../../server/lib/optional_plugin';
import { SpacesClient } from '../../../lib/spaces_client';
import { createSpaces } from './create_spaces';
import { ExternalRouteDeps } from '../external';
import { SpacesService } from '../../../new_platform/spaces_service';
import { SpacesAuditLogger } from '../../../lib/audit_logger';
import { LegacyAPI } from '../../../new_platform/plugin';
interface KibanaServer extends Legacy.Server {
savedObjects: any;
}
export interface TestConfig {
[configKey: string]: any;
}
export interface TestOptions {
setupFn?: (server: any) => void;
testConfig?: TestConfig;
payload?: any;
preCheckLicenseImpl?: (req: any, h: any) => any;
expectSpacesClientCall?: boolean;
expectPreCheckLicenseCall?: boolean;
}
export type TeardownFn = () => void;
export interface RequestRunnerResult {
server: any;
mockSavedObjectsRepository: any;
mockSavedObjectsService: {
getScopedSavedObjectsClient: jest.Mock<
SavedObjectsLegacyService['getScopedSavedObjectsClient']
>;
importExport: {
getSortedObjectsForExport: jest.Mock<
SavedObjectsLegacyService['importExport']['getSortedObjectsForExport']
>;
importSavedObjects: jest.Mock<
SavedObjectsLegacyService['importExport']['importSavedObjects']
>;
resolveImportErrors: jest.Mock<
SavedObjectsLegacyService['importExport']['resolveImportErrors']
>;
};
};
headers: Record<string, unknown>;
response: any;
}
export type RequestRunner = (
method: string,
path: string,
options?: TestOptions
) => Promise<RequestRunnerResult>;
export const defaultPreCheckLicenseImpl = (request: any) => '';
const baseConfig: TestConfig = {
'server.basePath': '',
};
async function readStreamToCompletion(stream: Readable) {
return (createPromiseFromStreams([stream, createConcatStream([])]) as unknown) as any[];
}
export function createTestHandler(initApiFn: (deps: ExternalRouteDeps) => void) {
const teardowns: TeardownFn[] = [];
const spaces = createSpaces();
const request: RequestRunner = async (
method: string,
path: string,
options: TestOptions = {}
) => {
const {
setupFn = () => {
return;
},
testConfig = {},
payload,
preCheckLicenseImpl = defaultPreCheckLicenseImpl,
expectPreCheckLicenseCall = true,
expectSpacesClientCall = true,
} = options;
let pre = jest.fn();
if (preCheckLicenseImpl) {
pre = pre.mockImplementation(preCheckLicenseImpl);
}
const server = new Server() as KibanaServer;
const config = {
...baseConfig,
...testConfig,
};
await setupFn(server);
const mockConfig = {
get: (key: string) => config[key],
};
server.decorate('server', 'config', jest.fn<any, any>(() => mockConfig));
const mockSavedObjectsClientContract = {
get: jest.fn((type, id) => {
const result = spaces.filter(s => s.id === id);
if (!result.length) {
throw new Error(`not found: [${type}:${id}]`);
}
return result[0];
}),
find: jest.fn(() => {
return {
total: spaces.length,
saved_objects: spaces,
};
}),
create: jest.fn((type, attributes, { id }) => {
if (spaces.find(s => s.id === id)) {
throw new Error('conflict');
}
return {};
}),
update: jest.fn((type, id) => {
if (!spaces.find(s => s.id === id)) {
throw new Error('not found: during update');
}
return {};
}),
delete: jest.fn((type: string, id: string) => {
return {};
}),
deleteByNamespace: jest.fn(),
};
server.savedObjects = {
types: ['visualization', 'dashboard', 'index-pattern', 'globalType'],
schema: new SavedObjectsSchema({
space: {
isNamespaceAgnostic: true,
hidden: true,
},
globalType: {
isNamespaceAgnostic: true,
},
}),
getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract),
importExport: {
getSortedObjectsForExport: jest.fn().mockResolvedValue(
new Readable({
objectMode: true,
read() {
if (Array.isArray(payload.objects)) {
payload.objects.forEach((o: any) => this.push(o));
}
this.push(null);
},
})
),
importSavedObjects: jest.fn().mockImplementation(async (opts: Record<string, any>) => {
const objectsToImport: any[] = await readStreamToCompletion(opts.readStream);
return {
success: true,
successCount: objectsToImport.length,
};
}),
resolveImportErrors: jest.fn().mockImplementation(async (opts: Record<string, any>) => {
const objectsToImport: any[] = await readStreamToCompletion(opts.readStream);
return {
success: true,
successCount: objectsToImport.length,
};
}),
},
SavedObjectsClient: {
errors: {
isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')),
isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')),
},
},
};
server.plugins.elasticsearch = {
createCluster: jest.fn(),
waitUntilReady: jest.fn(),
getCluster: jest.fn().mockReturnValue({
callWithRequest: jest.fn(),
callWithInternalUser: jest.fn(),
}),
};
const log = {
log: jest.fn(),
trace: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
};
const coreSetupMock = coreMock.createSetup();
const legacyAPI = {
legacyConfig: {
serverBasePath: mockConfig.get('server.basePath'),
serverDefaultRoute: mockConfig.get('server.defaultRoute'),
},
savedObjects: server.savedObjects,
} as LegacyAPI;
const service = new SpacesService(log, () => legacyAPI);
const spacesService = await service.setup({
http: coreSetupMock.http,
elasticsearch: elasticsearchServiceMock.createSetupContract(),
security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'),
getSpacesAuditLogger: () => ({} as SpacesAuditLogger),
config$: Rx.of({ maxSpaces: 1000 }),
});
spacesService.scopedClient = jest.fn((req: any) => {
return Promise.resolve(
new SpacesClient(
null as any,
() => null,
null,
mockSavedObjectsClientContract,
{ maxSpaces: 1000 },
mockSavedObjectsClientContract,
req
)
);
});
initApiFn({
routePreCheckLicenseFn: pre,
savedObjects: server.savedObjects,
spacesService,
log,
legacyRouter: server.route.bind(server),
});
teardowns.push(() => server.stop());
const headers = {
authorization: 'foo',
};
const testRun = async () => {
const response = await server.inject({
method,
url: path,
headers,
payload,
});
if (preCheckLicenseImpl && expectPreCheckLicenseCall) {
expect(pre).toHaveBeenCalled();
} else {
expect(pre).not.toHaveBeenCalled();
}
if (expectSpacesClientCall) {
expect(spacesService.scopedClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
authorization: headers.authorization,
}),
})
);
} else {
expect(spacesService.scopedClient).not.toHaveBeenCalled();
}
return response;
};
return {
server,
headers,
mockSavedObjectsRepository: mockSavedObjectsClientContract,
mockSavedObjectsService: server.savedObjects,
response: await testRun(),
};
};
return {
request,
teardowns,
};
}

View file

@ -1,443 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../lib/route_pre_check_license', () => {
return {
routePreCheckLicense: () => (request: any, h: any) => h.continue,
};
});
jest.mock('../../../../../../server/lib/get_client_shield', () => {
return {
getClient: () => {
return {
callWithInternalUser: jest.fn(() => {
return;
}),
};
},
};
});
import Boom from 'boom';
import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__';
import { initCopyToSpacesApi } from './copy_to_space';
describe('POST /api/spaces/_copy_saved_objects', () => {
let request: RequestRunner;
let teardowns: TeardownFn[];
beforeEach(() => {
const setup = createTestHandler(initCopyToSpacesApi);
request = setup.request;
teardowns = setup.teardowns;
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
test(`returns result of routePreCheckLicense`, async () => {
const payload = {
spaces: ['a-space'],
objects: [],
};
const { response } = await request('POST', '/api/spaces/_copy_saved_objects', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
expectSpacesClientCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(403);
expect(JSON.parse(responsePayload)).toMatchObject({
message: 'test forbidden message',
});
});
test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => {
const payload = {
spaces: ['a-space'],
objects: [],
};
const { mockSavedObjectsService } = await request('POST', '/api/spaces/_copy_saved_objects', {
expectSpacesClientCall: false,
payload,
});
expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith(
expect.any(Object),
{
excludedWrappers: ['spaces'],
}
);
});
test(`requires space IDs to be unique`, async () => {
const payload = {
spaces: ['a-space', 'a-space'],
objects: [],
};
const { response } = await request('POST', '/api/spaces/_copy_saved_objects', {
expectSpacesClientCall: false,
expectPreCheckLicenseCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(400);
expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(`
Object {
"error": "Bad Request",
"message": "Invalid request payload input",
"statusCode": 400,
}
`);
});
test(`requires well-formed space IDS`, async () => {
const payload = {
spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'],
objects: [],
};
const { response } = await request('POST', '/api/spaces/_copy_saved_objects', {
expectSpacesClientCall: false,
expectPreCheckLicenseCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(400);
expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(`
Object {
"error": "Bad Request",
"message": "Invalid request payload input",
"statusCode": 400,
}
`);
});
test(`requires objects to be unique`, async () => {
const payload = {
spaces: ['a-space'],
objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }],
};
const { response } = await request('POST', '/api/spaces/_copy_saved_objects', {
expectSpacesClientCall: false,
expectPreCheckLicenseCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(400);
expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(`
Object {
"error": "Bad Request",
"message": "Invalid request payload input",
"statusCode": 400,
}
`);
});
test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => {
const payload = {
spaces: ['a-space'],
objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }],
};
const { response, mockSavedObjectsService } = await request(
'POST',
'/api/spaces/_copy_saved_objects',
{
expectSpacesClientCall: false,
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(1);
const [
importCallOptions,
] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0];
expect(importCallOptions).toMatchObject({
namespace: 'a-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
});
test('copies to multiple spaces', async () => {
const payload = {
spaces: ['a-space', 'b-space'],
objects: [{ type: 'visualization', id: 'bar' }],
};
const { response, mockSavedObjectsService } = await request(
'POST',
'/api/spaces/_copy_saved_objects',
{
expectSpacesClientCall: false,
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(2);
const [
firstImportCallOptions,
] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0];
expect(firstImportCallOptions).toMatchObject({
namespace: 'a-space',
});
const [
secondImportCallOptions,
] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[1];
expect(secondImportCallOptions).toMatchObject({
namespace: 'b-space',
});
});
});
describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => {
let request: RequestRunner;
let teardowns: TeardownFn[];
beforeEach(() => {
const setup = createTestHandler(initCopyToSpacesApi);
request = setup.request;
teardowns = setup.teardowns;
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
test(`returns result of routePreCheckLicense`, async () => {
const payload = {
retries: {},
objects: [],
};
const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
expectSpacesClientCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(403);
expect(JSON.parse(responsePayload)).toMatchObject({
message: 'test forbidden message',
});
});
test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => {
const payload = {
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
],
},
objects: [{ type: 'visualization', id: 'bar' }],
};
const { mockSavedObjectsService } = await request(
'POST',
'/api/spaces/_resolve_copy_saved_objects_errors',
{
expectSpacesClientCall: false,
payload,
}
);
expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith(
expect.any(Object),
{
excludedWrappers: ['spaces'],
}
);
});
test(`requires objects to be unique`, async () => {
const payload = {
retries: {},
objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }],
};
const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', {
expectSpacesClientCall: false,
expectPreCheckLicenseCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(400);
expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(`
Object {
"error": "Bad Request",
"message": "Invalid request payload input",
"statusCode": 400,
}
`);
});
test(`requires well-formed space ids`, async () => {
const payload = {
retries: {
['invalid-space-id!@#$%^&*()']: [
{
type: 'foo',
id: 'bar',
overwrite: true,
},
],
},
objects: [{ type: 'foo', id: 'bar' }],
};
const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', {
expectSpacesClientCall: false,
expectPreCheckLicenseCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(400);
expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(`
Object {
"error": "Bad Request",
"message": "Invalid request payload input",
"statusCode": 400,
}
`);
});
test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => {
const payload = {
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
{
type: 'globalType',
id: 'bar',
overwrite: true,
},
],
},
objects: [
{
type: 'globalType',
id: 'bar',
},
{ type: 'visualization', id: 'bar' },
],
};
const { response, mockSavedObjectsService } = await request(
'POST',
'/api/spaces/_resolve_copy_saved_objects_errors',
{
expectSpacesClientCall: false,
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(1);
const [
resolveImportErrorsCallOptions,
] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0];
expect(resolveImportErrorsCallOptions).toMatchObject({
namespace: 'a-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
});
test('resolves conflicts for multiple spaces', async () => {
const payload = {
objects: [{ type: 'visualization', id: 'bar' }],
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
],
['b-space']: [
{
type: 'globalType',
id: 'bar',
overwrite: true,
},
],
},
};
const { response, mockSavedObjectsService } = await request(
'POST',
'/api/spaces/_resolve_copy_saved_objects_errors',
{
expectSpacesClientCall: false,
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(2);
const [
resolveImportErrorsFirstCallOptions,
] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0];
expect(resolveImportErrorsFirstCallOptions).toMatchObject({
namespace: 'a-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
const [
resolveImportErrorsSecondCallOptions,
] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[1];
expect(resolveImportErrorsSecondCallOptions).toMatchObject({
namespace: 'b-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
});
});

View file

@ -1,145 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { Legacy } from 'kibana';
import {
copySavedObjectsToSpacesFactory,
resolveCopySavedObjectsToSpacesConflictsFactory,
} from '../../../lib/copy_to_spaces';
import { ExternalRouteDeps } from '.';
import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces';
import { SPACE_ID_REGEX } from '../../../lib/space_schema';
interface CopyPayload {
spaces: string[];
objects: Array<{ type: string; id: string }>;
includeReferences: boolean;
overwrite: boolean;
}
interface ResolveConflictsPayload {
objects: Array<{ type: string; id: string }>;
includeReferences: boolean;
retries: {
[spaceId: string]: Array<{
type: string;
id: string;
overwrite: boolean;
}>;
};
}
export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
const { legacyRouter, spacesService, savedObjects, routePreCheckLicenseFn } = deps;
legacyRouter({
method: 'POST',
path: '/api/spaces/_copy_saved_objects',
async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) {
const savedObjectsClient = savedObjects.getScopedSavedObjectsClient(
request,
COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS
);
const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(
savedObjectsClient,
savedObjects
);
const {
spaces: destinationSpaceIds,
objects,
includeReferences,
overwrite,
} = request.payload as CopyPayload;
const sourceSpaceId = spacesService.getSpaceId(request);
const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, {
objects,
includeReferences,
overwrite,
});
return h.response(copyResponse);
},
options: {
tags: ['access:copySavedObjectsToSpaces'],
validate: {
payload: {
spaces: Joi.array()
.items(
Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`)
)
.unique(),
objects: Joi.array()
.items(Joi.object({ type: Joi.string(), id: Joi.string() }))
.unique(),
includeReferences: Joi.bool().default(false),
overwrite: Joi.bool().default(false),
},
},
pre: [routePreCheckLicenseFn],
},
});
legacyRouter({
method: 'POST',
path: '/api/spaces/_resolve_copy_saved_objects_errors',
async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) {
const savedObjectsClient = savedObjects.getScopedSavedObjectsClient(
request,
COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS
);
const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory(
savedObjectsClient,
savedObjects
);
const { objects, includeReferences, retries } = request.payload as ResolveConflictsPayload;
const sourceSpaceId = spacesService.getSpaceId(request);
const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts(
sourceSpaceId,
{
objects,
includeReferences,
retries,
}
);
return h.response(resolveConflictsResponse);
},
options: {
tags: ['access:copySavedObjectsToSpaces'],
validate: {
payload: Joi.object({
objects: Joi.array()
.items(Joi.object({ type: Joi.string(), id: Joi.string() }))
.required()
.unique(),
includeReferences: Joi.bool().default(false),
retries: Joi.object()
.pattern(
SPACE_ID_REGEX,
Joi.array().items(
Joi.object({
type: Joi.string().required(),
id: Joi.string().required(),
overwrite: Joi.boolean().default(false),
})
)
)
.required(),
}).default(),
},
pre: [routePreCheckLicenseFn],
},
});
}

View file

@ -1,85 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../lib/route_pre_check_license', () => {
return {
routePreCheckLicense: () => (request: any, h: any) => h.continue,
};
});
jest.mock('../../../../../../server/lib/get_client_shield', () => {
return {
getClient: () => {
return {
callWithInternalUser: jest.fn(() => {
return;
}),
};
},
};
});
import Boom from 'boom';
import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__';
import { initDeleteSpacesApi } from './delete';
describe('Spaces Public API', () => {
let request: RequestRunner;
let teardowns: TeardownFn[];
beforeEach(() => {
const setup = createTestHandler(initDeleteSpacesApi);
request = setup.request;
teardowns = setup.teardowns;
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
test(`'DELETE spaces/{id}' deletes the space`, async () => {
const { response } = await request('DELETE', '/api/spaces/space/a-space');
const { statusCode } = response;
expect(statusCode).toEqual(204);
});
test(`returns result of routePreCheckLicense`, async () => {
const { response } = await request('DELETE', '/api/spaces/space/a-space', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
expectSpacesClientCall: false,
});
const { statusCode, payload } = response;
expect(statusCode).toEqual(403);
expect(JSON.parse(payload)).toMatchObject({
message: 'test forbidden message',
});
});
test('DELETE spaces/{id} throws when deleting a non-existent space', async () => {
const { response } = await request('DELETE', '/api/spaces/space/not-a-space');
const { statusCode } = response;
expect(statusCode).toEqual(404);
});
test(`'DELETE spaces/{id}' cannot delete reserved spaces`, async () => {
const { response } = await request('DELETE', '/api/spaces/space/default');
const { statusCode, payload } = response;
expect(statusCode).toEqual(400);
expect(JSON.parse(payload)).toEqual({
statusCode: 400,
error: 'Bad Request',
message: 'This Space cannot be deleted because it is reserved.',
});
});
});

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { wrapError } from '../../../lib/errors';
import { SpacesClient } from '../../../lib/spaces_client';
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
export function initDeleteSpacesApi(deps: ExternalRouteDeps) {
const { legacyRouter, savedObjects, spacesService, routePreCheckLicenseFn } = deps;
legacyRouter({
method: 'DELETE',
path: '/api/spaces/space/{id}',
async handler(request: ExternalRouteRequestFacade, h: any) {
const { SavedObjectsClient } = savedObjects;
const spacesClient: SpacesClient = await spacesService.scopedClient(request);
const id = request.params.id;
let result;
try {
result = await spacesClient.delete(id);
} catch (error) {
if (SavedObjectsClient.errors.isNotFoundError(error)) {
return Boom.notFound();
}
return wrapError(error);
}
return h.response(result).code(204);
},
options: {
pre: [routePreCheckLicenseFn],
},
});
}

View file

@ -1,109 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../lib/route_pre_check_license', () => {
return {
routePreCheckLicense: () => (request: any, h: any) => h.continue,
};
});
jest.mock('../../../../../../server/lib/get_client_shield', () => {
return {
getClient: () => {
return {
callWithInternalUser: jest.fn(() => {
return;
}),
};
},
};
});
import Boom from 'boom';
import { Space } from '../../../../common/model/space';
import { createSpaces, createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__';
import { initGetSpacesApi } from './get';
describe('GET spaces', () => {
let request: RequestRunner;
let teardowns: TeardownFn[];
const spaces = createSpaces();
beforeEach(() => {
const setup = createTestHandler(initGetSpacesApi);
request = setup.request;
teardowns = setup.teardowns;
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
test(`'GET spaces' returns all available spaces`, async () => {
const { response } = await request('GET', '/api/spaces/space');
const { statusCode, payload } = response;
expect(statusCode).toEqual(200);
const resultSpaces: Space[] = JSON.parse(payload);
expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id));
});
test(`'GET spaces' returns all available spaces with the 'any' purpose`, async () => {
const { response } = await request('GET', '/api/spaces/space?purpose=any');
const { statusCode, payload } = response;
expect(statusCode).toEqual(200);
const resultSpaces: Space[] = JSON.parse(payload);
expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id));
});
test(`'GET spaces' returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => {
const { response } = await request(
'GET',
'/api/spaces/space?purpose=copySavedObjectsIntoSpace'
);
const { statusCode, payload } = response;
expect(statusCode).toEqual(200);
const resultSpaces: Space[] = JSON.parse(payload);
expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id));
});
test(`returns result of routePreCheckLicense`, async () => {
const { response } = await request('GET', '/api/spaces/space', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
expectSpacesClientCall: false,
});
const { statusCode, payload } = response;
expect(statusCode).toEqual(403);
expect(JSON.parse(payload)).toMatchObject({
message: 'test forbidden message',
});
});
test(`'GET spaces/{id}' returns the space with that id`, async () => {
const { response } = await request('GET', '/api/spaces/space/default');
const { statusCode, payload } = response;
expect(statusCode).toEqual(200);
const resultSpace = JSON.parse(payload);
expect(resultSpace.id).toEqual('default');
});
test(`'GET spaces/{id}' returns 404 when retrieving a non-existent space`, async () => {
const { response } = await request('GET', '/api/spaces/space/not-a-space');
const { statusCode } = response;
expect(statusCode).toEqual(404);
});
});

View file

@ -1,76 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import Joi from 'joi';
import { RequestQuery } from 'hapi';
import { GetSpacePurpose } from '../../../../common/model/types';
import { Space } from '../../../../common/model/space';
import { wrapError } from '../../../lib/errors';
import { SpacesClient } from '../../../lib/spaces_client';
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
export function initGetSpacesApi(deps: ExternalRouteDeps) {
const { legacyRouter, log, spacesService, savedObjects, routePreCheckLicenseFn } = deps;
legacyRouter({
method: 'GET',
path: '/api/spaces/space',
async handler(request: ExternalRouteRequestFacade) {
log.debug(`Inside GET /api/spaces/space`);
const purpose: GetSpacePurpose = (request.query as RequestQuery).purpose as GetSpacePurpose;
const spacesClient: SpacesClient = await spacesService.scopedClient(request);
let spaces: Space[];
try {
log.debug(`Attempting to retrieve all spaces for ${purpose} purpose`);
spaces = await spacesClient.getAll(purpose);
log.debug(`Retrieved ${spaces.length} spaces for ${purpose} purpose`);
} catch (error) {
log.debug(`Error retrieving spaces for ${purpose} purpose: ${error}`);
return wrapError(error);
}
return spaces;
},
options: {
pre: [routePreCheckLicenseFn],
validate: {
query: Joi.object().keys({
purpose: Joi.string()
.valid('any', 'copySavedObjectsIntoSpace')
.default('any'),
}),
},
},
});
legacyRouter({
method: 'GET',
path: '/api/spaces/space/{id}',
async handler(request: ExternalRouteRequestFacade) {
const spaceId = request.params.id;
const { SavedObjectsClient } = savedObjects;
const spacesClient: SpacesClient = await spacesService.scopedClient(request);
try {
return await spacesClient.get(spaceId);
} catch (error) {
if (SavedObjectsClient.errors.isNotFoundError(error)) {
return Boom.notFound();
}
return wrapError(error);
}
},
options: {
pre: [routePreCheckLicenseFn],
},
});
}

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { Logger, SavedObjectsLegacyService } from 'src/core/server';
import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main';
import { routePreCheckLicense } from '../../../lib/route_pre_check_license';
import { initDeleteSpacesApi } from './delete';
import { initGetSpacesApi } from './get';
import { initPostSpacesApi } from './post';
import { initPutSpacesApi } from './put';
import { SpacesServiceSetup } from '../../../new_platform/spaces_service/spaces_service';
import { initCopyToSpacesApi } from './copy_to_space';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface RouteDeps {
xpackMain: XPackMainPlugin;
legacyRouter: Legacy.Server['route'];
savedObjects: SavedObjectsLegacyService;
spacesService: SpacesServiceSetup;
log: Logger;
}
export interface ExternalRouteDeps extends Omit<RouteDeps, 'xpackMain'> {
routePreCheckLicenseFn: any;
}
export type ExternalRouteRequestFacade = Legacy.Request;
export function initExternalSpacesApi({ xpackMain, ...rest }: RouteDeps) {
const routePreCheckLicenseFn = routePreCheckLicense({ xpackMain });
const deps: ExternalRouteDeps = {
...rest,
routePreCheckLicenseFn,
};
initDeleteSpacesApi(deps);
initGetSpacesApi(deps);
initPostSpacesApi(deps);
initPutSpacesApi(deps);
initCopyToSpacesApi(deps);
}

View file

@ -1,128 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../lib/route_pre_check_license', () => {
return {
routePreCheckLicense: () => (request: any, h: any) => h.continue,
};
});
jest.mock('../../../../../../server/lib/get_client_shield', () => {
return {
getClient: () => {
return {
callWithInternalUser: jest.fn(() => {
return;
}),
};
},
};
});
import Boom from 'boom';
import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__';
import { initPostSpacesApi } from './post';
describe('Spaces Public API', () => {
let request: RequestRunner;
let teardowns: TeardownFn[];
beforeEach(() => {
const setup = createTestHandler(initPostSpacesApi);
request = setup.request;
teardowns = setup.teardowns;
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
test('POST /space should create a new space with the provided ID', async () => {
const payload = {
id: 'my-space-id',
name: 'my new space',
description: 'with a description',
disabledFeatures: ['foo'],
};
const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', {
payload,
});
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith(
'space',
{ name: 'my new space', description: 'with a description', disabledFeatures: ['foo'] },
{ id: 'my-space-id' }
);
});
test(`returns result of routePreCheckLicense`, async () => {
const payload = {
id: 'my-space-id',
name: 'my new space',
description: 'with a description',
};
const { response } = await request('POST', '/api/spaces/space', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
expectSpacesClientCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(403);
expect(JSON.parse(responsePayload)).toMatchObject({
message: 'test forbidden message',
});
});
test('POST /space should not allow a space to be updated', async () => {
const payload = {
id: 'a-space',
name: 'my updated space',
description: 'with a description',
};
const { response } = await request('POST', '/api/spaces/space', { payload });
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(409);
expect(JSON.parse(responsePayload)).toEqual({
error: 'Conflict',
message: 'A space with the identifier a-space already exists.',
statusCode: 409,
});
});
test('POST /space should not require disabledFeatures to be specified', async () => {
const payload = {
id: 'my-space-id',
name: 'my new space',
description: 'with a description',
};
const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', {
payload,
});
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith(
'space',
{ name: 'my new space', description: 'with a description', disabledFeatures: [] },
{ id: 'my-space-id' }
);
});
});

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { Space } from '../../../../common/model/space';
import { wrapError } from '../../../lib/errors';
import { spaceSchema } from '../../../lib/space_schema';
import { SpacesClient } from '../../../lib/spaces_client';
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
export function initPostSpacesApi(deps: ExternalRouteDeps) {
const { legacyRouter, log, spacesService, savedObjects, routePreCheckLicenseFn } = deps;
legacyRouter({
method: 'POST',
path: '/api/spaces/space',
async handler(request: ExternalRouteRequestFacade) {
log.debug(`Inside POST /api/spaces/space`);
const { SavedObjectsClient } = savedObjects;
const spacesClient: SpacesClient = await spacesService.scopedClient(request);
const space = request.payload as Space;
try {
log.debug(`Attempting to create space`);
return await spacesClient.create(space);
} catch (error) {
if (SavedObjectsClient.errors.isConflictError(error)) {
return Boom.conflict(`A space with the identifier ${space.id} already exists.`);
}
log.debug(`Error creating space: ${error}`);
return wrapError(error);
}
},
options: {
validate: {
payload: spaceSchema,
},
pre: [routePreCheckLicenseFn],
},
});
}

View file

@ -1,156 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../lib/route_pre_check_license', () => {
return {
routePreCheckLicense: () => (request: any, h: any) => h.continue,
};
});
jest.mock('../../../../../../server/lib/get_client_shield', () => {
return {
getClient: () => {
return {
callWithInternalUser: jest.fn(() => {
return;
}),
};
},
};
});
import Boom from 'boom';
import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__';
import { initPutSpacesApi } from './put';
describe('Spaces Public API', () => {
let request: RequestRunner;
let teardowns: TeardownFn[];
beforeEach(() => {
const setup = createTestHandler(initPutSpacesApi);
request = setup.request;
teardowns = setup.teardowns;
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
test('PUT /space should update an existing space with the provided ID', async () => {
const payload = {
id: 'a-space',
name: 'my updated space',
description: 'with a description',
disabledFeatures: [],
};
const { mockSavedObjectsRepository, response } = await request(
'PUT',
'/api/spaces/space/a-space',
{
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', {
name: 'my updated space',
description: 'with a description',
disabledFeatures: [],
});
});
test('PUT /space should allow an empty description', async () => {
const payload = {
id: 'a-space',
name: 'my updated space',
description: '',
disabledFeatures: ['foo'],
};
const { mockSavedObjectsRepository, response } = await request(
'PUT',
'/api/spaces/space/a-space',
{
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', {
name: 'my updated space',
description: '',
disabledFeatures: ['foo'],
});
});
test('PUT /space should not require disabledFeatures', async () => {
const payload = {
id: 'a-space',
name: 'my updated space',
description: '',
};
const { mockSavedObjectsRepository, response } = await request(
'PUT',
'/api/spaces/space/a-space',
{
payload,
}
);
const { statusCode } = response;
expect(statusCode).toEqual(200);
expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', {
name: 'my updated space',
description: '',
disabledFeatures: [],
});
});
test(`returns result of routePreCheckLicense`, async () => {
const payload = {
id: 'a-space',
name: 'my updated space',
description: 'with a description',
};
const { response } = await request('PUT', '/api/spaces/space/a-space', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
expectSpacesClientCall: false,
payload,
});
const { statusCode, payload: responsePayload } = response;
expect(statusCode).toEqual(403);
expect(JSON.parse(responsePayload)).toMatchObject({
message: 'test forbidden message',
});
});
test('PUT /space should not allow a new space to be created', async () => {
const payload = {
id: 'a-new-space',
name: 'my new space',
description: 'with a description',
};
const { response } = await request('PUT', '/api/spaces/space/a-new-space', { payload });
const { statusCode } = response;
expect(statusCode).toEqual(404);
});
});

View file

@ -1,46 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { Space } from '../../../../common/model/space';
import { wrapError } from '../../../lib/errors';
import { spaceSchema } from '../../../lib/space_schema';
import { SpacesClient } from '../../../lib/spaces_client';
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
export function initPutSpacesApi(deps: ExternalRouteDeps) {
const { legacyRouter, spacesService, savedObjects, routePreCheckLicenseFn } = deps;
legacyRouter({
method: 'PUT',
path: '/api/spaces/space/{id}',
async handler(request: ExternalRouteRequestFacade) {
const { SavedObjectsClient } = savedObjects;
const spacesClient: SpacesClient = await spacesService.scopedClient(request);
const space: Space = request.payload as Space;
const id = request.params.id;
let result: Space;
try {
result = await spacesClient.update(id, { ...space });
} catch (error) {
if (SavedObjectsClient.errors.isNotFoundError(error)) {
return Boom.notFound();
}
return wrapError(error);
}
return result;
},
options: {
validate: {
payload: spaceSchema,
},
pre: [routePreCheckLicenseFn],
},
});
}

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Space } from '../../../common/model/space';
import { SpacesClient } from '../../lib/spaces_client';
import { convertSavedObjectToSpace } from './convert_saved_object_to_space';
export async function getSpaceById(
client: SpacesClient,
spaceId: string,
errors: any
): Promise<Space | null> {
try {
const existingSpace = await client.get(spaceId);
return convertSavedObjectToSpace(existingSpace);
} catch (error) {
if (errors.isNotFoundError(error)) {
return null;
}
throw error;
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const DEFAULT_SPACE_ID = `default`;
/**
* The minimum number of spaces required to show a search control.
*/
export const SPACE_SEARCH_COUNT_THRESHOLD = 8;
/**
* The maximum number of characters allowed in the Space Avatar's initials
*/
export const MAX_SPACE_INITIALS = 2;
/**
* The type name used within the Monitoring index to publish spaces stats.
* @type {string}
*/
export const KIBANA_SPACES_STATS_TYPE = 'spaces';
/**
* The path to enter a space.
*/
export const ENTER_SPACE_PATH = '/spaces/enter';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { isReservedSpace } from './is_reserved_space';
export { MAX_SPACE_INITIALS } from './constants';
export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser';

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isReservedSpace } from './is_reserved_space';
import { Space } from './model/space';
test('it returns true for reserved spaces', () => {
const space: Space = {
id: '',
name: '',
disabledFeatures: [],
_reserved: true,
};
expect(isReservedSpace(space)).toEqual(true);
});
test('it returns false for non-reserved spaces', () => {
const space: Space = {
id: '',
name: '',
disabledFeatures: [],
};
expect(isReservedSpace(space)).toEqual(false);
});
test('it handles empty input', () => {
// @ts-ignore
expect(isReservedSpace()).toEqual(false);
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import { Space } from './model/space';
/**
* Returns whether the given Space is reserved or not.
*
* @param space the space
* @returns boolean
*/
export function isReservedSpace(space?: Partial<Space> | null): boolean {
return get(space, '_reserved', false);
}

View file

@ -4,9 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from 'src/core/server';
import { Plugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new Plugin(initializerContext);
export interface Space {
id: string;
name: string;
description?: string;
color?: string;
initials?: string;
disabledFeatures: string[];
_reserved?: boolean;
imageUrl?: string;
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace';

View file

@ -0,0 +1,9 @@
{
"id": "spaces",
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "spaces"],
"requiredPlugins": ["features", "licensing"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginInitializerContext } from 'src/core/server';
import { Observable } from 'rxjs';
export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
maxSpaces: schema.number({ defaultValue: 1000 }),
});
export function createConfig$(context: PluginInitializerContext) {
return context.config.create<TypeOf<typeof ConfigSchema>>();
}
export type ConfigType = ReturnType<typeof createConfig$> extends Observable<infer P>
? P
: ReturnType<typeof createConfig$>;

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from '../../../../src/core/server';
import { ConfigSchema } from './config';
import { Plugin } from './plugin';
// These exports are part of public Spaces plugin contract, any change in signature of exported
// functions or removal of exports should be considered as a breaking change. Ideally we should
// reduce number of such exports to zero and provide everything we want to expose via Setup/Start
// run-time contracts.
// end public contract exports
export { SpacesPluginSetup } from './plugin';
export const config = { schema: ConfigSchema };
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { spacesConfig } from './spaces_config';

View file

@ -4,12 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { ConfigSchema } from '../../config';
export const config = {
schema: schema.object({
maxSpaces: schema.number({ defaultValue: 1000 }),
}),
};
export type SpacesConfigType = TypeOf<typeof config.schema>;
export const spacesConfig = ConfigSchema.validate({});

View file

@ -3,23 +3,11 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../../server/lib/get_client_shield', () => ({
getClient: jest.fn(),
}));
import * as Rx from 'rxjs';
import Boom from 'boom';
import { getClient } from '../../../../server/lib/get_client_shield';
import { createDefaultSpace } from './create_default_space';
import { SavedObjectsLegacyService } from 'src/core/server';
import { ElasticsearchServiceSetup } from 'src/core/server';
let mockCallWithRequest;
beforeEach(() => {
mockCallWithRequest = jest.fn();
(getClient as jest.Mock).mockReturnValue({
callWithRequest: mockCallWithRequest,
});
});
import Boom from 'boom';
import { createDefaultSpace } from './create_default_space';
import { SavedObjectsLegacyService, IClusterClient } from 'src/core/server';
interface MockServerSettings {
defaultExists?: boolean;
simulateGetErrorCondition?: boolean;
@ -84,11 +72,9 @@ const createMockDeps = (settings: MockServerSettings = {}) => {
return {
config: mockServer.config(),
savedObjects: (mockServer.savedObjects as unknown) as SavedObjectsLegacyService,
elasticsearch: ({
dataClient$: Rx.of({
callAsInternalUser: jest.fn(),
}),
} as unknown) as ElasticsearchServiceSetup,
esClient: ({
callAsInternalUser: jest.fn(),
} as unknown) as jest.Mocked<IClusterClient>,
};
};

View file

@ -5,22 +5,18 @@
*/
import { i18n } from '@kbn/i18n';
import { first } from 'rxjs/operators';
import { SavedObjectsLegacyService, CoreSetup } from 'src/core/server';
import { SavedObjectsLegacyService, IClusterClient } from 'src/core/server';
import { DEFAULT_SPACE_ID } from '../../common/constants';
interface Deps {
elasticsearch: CoreSetup['elasticsearch'];
esClient: Pick<IClusterClient, 'callAsInternalUser'>;
savedObjects: SavedObjectsLegacyService;
}
export async function createDefaultSpace({ elasticsearch, savedObjects }: Deps) {
export async function createDefaultSpace({ esClient, savedObjects }: Deps) {
const { getSavedObjectsRepository, SavedObjectsClient } = savedObjects;
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
const savedObjectsRepository = getSavedObjectsRepository(client.callAsInternalUser, ['space']);
const savedObjectsRepository = getSavedObjectsRepository(esClient.callAsInternalUser, ['space']);
const defaultSpaceExists = await doesDefaultSpaceExist(
SavedObjectsClient,

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { boomify, isBoom } from 'boom';
import { ResponseError, CustomHttpResponseOptions } from 'src/core/server';
export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> {
const boom = isBoom(error) ? error : boomify(error);
return {
body: boom,
headers: boom.output.headers,
statusCode: boom.output.statusCode,
};
}

View file

@ -4,6 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export function getSpaceSelectorUrl(serverBasePath: string = '') {
export function getSpaceSelectorUrl(serverBasePath: string) {
return `${serverBasePath}/spaces/space_selector`;
}

View file

@ -5,12 +5,24 @@
*/
import { getSpacesUsageCollector, UsageStats } from './get_spaces_usage_collector';
import * as Rx from 'rxjs';
import { PluginsSetup } from '../plugin';
import { Feature } from '../../../features/server';
import { ILicense, LicensingPluginSetup } from '../../../licensing/server';
function getServerMock(customization?: any) {
interface SetupOpts {
license?: Partial<ILicense>;
features?: Feature[];
}
function setup({
license = { isAvailable: true },
features = [{ id: 'feature1' } as Feature, { id: 'feature2' } as Feature],
}: SetupOpts = {}) {
class MockUsageCollector {
private fetch: any;
constructor(server: any, { fetch }: any) {
constructor({ fetch }: any) {
this.fetch = fetch;
}
// to make typescript happy
@ -19,39 +31,23 @@ function getServerMock(customization?: any) {
}
}
const getLicenseCheckResults = jest.fn().mockReturnValue({});
const defaultServerMock = {
plugins: {
xpack_main: {
info: {
isAvailable: jest.fn().mockReturnValue(true),
feature: () => ({
getLicenseCheckResults,
}),
license: {
isOneOf: jest.fn().mockReturnValue(false),
getType: jest.fn().mockReturnValue('platinum'),
},
toJSON: () => ({ b: 1 }),
},
getFeatures: jest.fn().mockReturnValue([{ id: 'feature1' }, { id: 'feature2' }]),
},
},
expose: () => {
return;
},
log: () => {
return;
},
const licensing = {
license$: Rx.of(license),
} as LicensingPluginSetup;
const featuresSetup = ({
getFeatures: jest.fn().mockReturnValue(features),
} as unknown) as PluginsSetup['features'];
return {
licensing,
features: featuresSetup,
usage: {
collectorSet: {
makeUsageCollector: (options: any) => {
return new MockUsageCollector(defaultServerMock, options);
},
makeUsageCollector: (options: any) => new MockUsageCollector(options),
},
},
};
return Object.assign(defaultServerMock, customization);
}
const defaultCallClusterMock = jest.fn().mockResolvedValue({
@ -73,17 +69,14 @@ const defaultCallClusterMock = jest.fn().mockResolvedValue({
});
describe('with a basic license', () => {
let serverWithBasicLicenseMock: any;
let usageStats: UsageStats;
beforeAll(async () => {
serverWithBasicLicenseMock = getServerMock();
serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = jest
.fn()
.mockReturnValue('basic');
const { features, licensing, usage } = setup({ license: { isAvailable: true, type: 'basic' } });
const { fetch: getSpacesUsage } = getSpacesUsageCollector({
kibanaIndex: '.kibana',
usage: serverWithBasicLicenseMock.usage,
xpackMain: serverWithBasicLicenseMock.plugins.xpack_main,
usage,
features,
licensing,
});
usageStats = await getSpacesUsage(defaultCallClusterMock);
});
@ -113,13 +106,12 @@ describe('with a basic license', () => {
describe('with no license', () => {
let usageStats: UsageStats;
beforeAll(async () => {
const serverWithNoLicenseMock = getServerMock();
serverWithNoLicenseMock.plugins.xpack_main.info.isAvailable = jest.fn().mockReturnValue(false);
const { features, licensing, usage } = setup({ license: { isAvailable: false } });
const { fetch: getSpacesUsage } = getSpacesUsageCollector({
kibanaIndex: '.kibana',
usage: serverWithNoLicenseMock.usage,
xpackMain: serverWithNoLicenseMock.plugins.xpack_main,
usage,
features,
licensing,
});
usageStats = await getSpacesUsage(defaultCallClusterMock);
});
@ -142,17 +134,16 @@ describe('with no license', () => {
});
describe('with platinum license', () => {
let serverWithPlatinumLicenseMock: any;
let usageStats: UsageStats;
beforeAll(async () => {
serverWithPlatinumLicenseMock = getServerMock();
serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = jest
.fn()
.mockReturnValue('platinum');
const { features, licensing, usage } = setup({
license: { isAvailable: true, type: 'platinum' },
});
const { fetch: getSpacesUsage } = getSpacesUsageCollector({
kibanaIndex: '.kibana',
usage: serverWithPlatinumLicenseMock.usage,
xpackMain: serverWithPlatinumLicenseMock.plugins.xpack_main,
usage,
features,
licensing,
});
usageStats = await getSpacesUsage(defaultCallClusterMock);
});

View file

@ -6,10 +6,11 @@
import { get } from 'lodash';
import { CallAPIOptions } from 'src/core/server';
import { XPackMainPlugin } from '../../../xpack_main/xpack_main';
import { take } from 'rxjs/operators';
// @ts-ignore
import { KIBANA_STATS_TYPE_MONITORING } from '../../../monitoring/common/constants';
import { KIBANA_STATS_TYPE_MONITORING } from '../../../../legacy/plugins/monitoring/common/constants';
import { KIBANA_SPACES_STATS_TYPE } from '../../common/constants';
import { PluginsSetup } from '../plugin';
type CallCluster = <T = unknown>(
endpoint: string,
@ -30,22 +31,23 @@ interface SpacesAggregationResponse {
/**
*
* @param callCluster
* @param server
* @param {CallCluster} callCluster
* @param {string} kibanaIndex
* @param {PluginsSetup['features']} features
* @param {boolean} spacesAvailable
* @return {UsageStats}
*/
async function getSpacesUsage(
callCluster: CallCluster,
kibanaIndex: string,
xpackMainPlugin: XPackMainPlugin,
features: PluginsSetup['features'],
spacesAvailable: boolean
) {
if (!spacesAvailable) {
return {} as UsageStats;
}
const knownFeatureIds = xpackMainPlugin.getFeatures().map(feature => feature.id);
const knownFeatureIds = features.getFeatures().map(feature => feature.id);
const resp = await callCluster<SpacesAggregationResponse>('search', {
index: kibanaIndex,
@ -115,7 +117,8 @@ export interface UsageStats {
interface CollectorDeps {
kibanaIndex: string;
usage: { collectorSet: any };
xpackMain: XPackMainPlugin;
features: PluginsSetup['features'];
licensing: PluginsSetup['licensing'];
}
/*
@ -128,13 +131,13 @@ export function getSpacesUsageCollector(deps: CollectorDeps) {
type: KIBANA_SPACES_STATS_TYPE,
isReady: () => true,
fetch: async (callCluster: CallCluster) => {
const xpackInfo = deps.xpackMain.info;
const available = xpackInfo && xpackInfo.isAvailable(); // some form of spaces is available for all valid licenses
const license = await deps.licensing.license$.pipe(take(1)).toPromise();
const available = license.isAvailable; // some form of spaces is available for all valid licenses
const usageStats = await getSpacesUsage(
callCluster,
deps.kibanaIndex,
deps.xpackMain,
deps.features,
available
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { migrateToKibana660 } from './migrate_6x';

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { migrateToKibana660 } from './migrate_6x';
describe('migrateTo660', () => {
it('adds a "disabledFeatures" attribute initialized as an empty array', () => {
expect(
migrateToKibana660({
id: 'space:foo',
attributes: {},
})
).toEqual({
id: 'space:foo',
attributes: {
disabledFeatures: [],
},
});
});
it('does not initialize "disabledFeatures" if the property already exists', () => {
// This scenario shouldn't happen organically. Protecting against defects in the migration.
expect(
migrateToKibana660({
id: 'space:foo',
attributes: {
disabledFeatures: ['foo', 'bar', 'baz'],
},
})
).toEqual({
id: 'space:foo',
attributes: {
disabledFeatures: ['foo', 'bar', 'baz'],
},
});
});
});

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function migrateToKibana660(doc: Record<string, any>) {
if (!doc.attributes.hasOwnProperty('disabledFeatures')) {
doc.attributes.disabledFeatures = [];
}
return doc;
}

View file

@ -14,26 +14,25 @@ import {
CoreSetup,
SavedObjectsLegacyService,
SavedObjectsErrorHelpers,
} from '../../../../../../../src/core/server';
} from '../../../../../../src/core/server';
import {
elasticsearchServiceMock,
loggingServiceMock,
} from '../../../../../../../src/core/server/mocks';
import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server';
import { LegacyAPI } from '../../new_platform/plugin';
import { SpacesService } from '../../new_platform/spaces_service';
import { OptionalPlugin } from '../../../../../server/lib/optional_plugin';
} from '../../../../../../src/core/server/mocks';
import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server';
import { LegacyAPI, PluginsSetup } from '../../plugin';
import { SpacesService } from '../../spaces_service';
import { SpacesAuditLogger } from '../audit_logger';
import { SecurityPlugin } from '../../../../security';
import { convertSavedObjectToSpace } from '../../routes/lib';
import { XPackMainPlugin } from '../../../../xpack_main/xpack_main';
import { Feature } from '../../../../../../plugins/features/server';
import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor';
import { Feature } from '../../../../features/server';
import { OptionalPlugin } from '../../../../../legacy/server/lib/optional_plugin';
import { SecurityPlugin } from '../../../../../legacy/plugins/security';
import { spacesConfig } from '../__fixtures__';
describe('onPostAuthInterceptor', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
const defaultRoute = '/app/kibana';
const headers = {
authorization: `Basic ${Buffer.from(
`${kibanaTestUser.username}:${kibanaTestUser.password}`
@ -117,7 +116,7 @@ describe('onPostAuthInterceptor', () => {
.asLoggerFactory()
.get('xpack', 'spaces');
const xpackMainPlugin = {
const featuresPlugin = {
getFeatures: () =>
[
{
@ -141,7 +140,7 @@ describe('onPostAuthInterceptor', () => {
app: ['kibana'],
},
] as Feature[],
} as XPackMainPlugin;
} as PluginsSetup['features'];
const savedObjectsService = {
SavedObjectsClient: {
@ -164,10 +163,6 @@ describe('onPostAuthInterceptor', () => {
};
const legacyAPI = {
legacyConfig: {
serverDefaultRoute: defaultRoute,
serverBasePath: '',
},
savedObjects: (savedObjectsService as unknown) as SavedObjectsLegacyService,
} as LegacyAPI;
@ -176,9 +171,9 @@ describe('onPostAuthInterceptor', () => {
const spacesService = await service.setup({
http: (http as unknown) as CoreSetup['http'],
elasticsearch: elasticsearchServiceMock.createSetupContract(),
security: {} as OptionalPlugin<SecurityPlugin>,
getSecurity: () => ({} as OptionalPlugin<SecurityPlugin>),
getSpacesAuditLogger: () => ({} as SpacesAuditLogger),
config$: Rx.of({ maxSpaces: 1000 }),
config$: Rx.of(spacesConfig),
});
spacesService.scopedClient = jest.fn().mockResolvedValue({
@ -212,7 +207,7 @@ describe('onPostAuthInterceptor', () => {
getLegacyAPI: () => legacyAPI,
http: (http as unknown) as CoreSetup['http'],
log: loggingMock,
xpackMain: xpackMainPlugin,
features: featuresPlugin,
spacesService,
});

View file

@ -6,9 +6,8 @@
import { Logger, CoreSetup } from 'src/core/server';
import { Space } from '../../../common/model/space';
import { wrapError } from '../errors';
import { XPackMainPlugin } from '../../../../xpack_main/xpack_main';
import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service';
import { LegacyAPI } from '../../new_platform/plugin';
import { SpacesServiceSetup } from '../../spaces_service/spaces_service';
import { LegacyAPI, PluginsSetup } from '../../plugin';
import { getSpaceSelectorUrl } from '../get_space_selector_url';
import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants';
import { addSpaceIdToPath } from '../../../common';
@ -16,21 +15,21 @@ import { addSpaceIdToPath } from '../../../common';
export interface OnPostAuthInterceptorDeps {
getLegacyAPI(): LegacyAPI;
http: CoreSetup['http'];
xpackMain: XPackMainPlugin;
features: PluginsSetup['features'];
spacesService: SpacesServiceSetup;
log: Logger;
}
export function initSpacesOnPostAuthRequestInterceptor({
xpackMain,
features,
getLegacyAPI,
spacesService,
log,
http,
}: OnPostAuthInterceptorDeps) {
const { serverBasePath } = getLegacyAPI().legacyConfig;
http.registerOnPostAuth(async (request, response, toolkit) => {
const serverBasePath = http.basePath.serverBasePath;
const path = request.url.pathname!;
const spaceId = spacesService.getSpaceId(request);
@ -66,12 +65,7 @@ export function initSpacesOnPostAuthRequestInterceptor({
});
}
} catch (error) {
const wrappedError = wrapError(error);
return response.customError({
body: wrappedError,
headers: wrappedError.output.headers,
statusCode: wrappedError.output.statusCode,
});
return response.customError(wrapError(error));
}
} else if (isRequestingSpaceRoot) {
const destination = addSpaceIdToPath(serverBasePath, spaceId, ENTER_SPACE_PATH);
@ -89,7 +83,7 @@ export function initSpacesOnPostAuthRequestInterceptor({
} catch (error) {
const wrappedError = wrapError(error);
const statusCode = wrappedError.output.statusCode;
const statusCode = wrappedError.statusCode;
// If user is not authorized, or the space cannot be found, allow them to select another space
// by redirecting to the space selector.
@ -106,11 +100,7 @@ export function initSpacesOnPostAuthRequestInterceptor({
});
} else {
log.error(`Unable to navigate to space "${spaceId}". ${error}`);
return response.customError({
body: wrappedError,
headers: wrappedError.output.headers,
statusCode: wrappedError.output.statusCode,
});
return response.customError(wrappedError);
}
}
@ -120,7 +110,7 @@ export function initSpacesOnPostAuthRequestInterceptor({
if (appId !== 'kibana' && space && space.disabledFeatures.length > 0) {
log.debug(`Verifying application is available: "${appId}"`);
const allFeatures = xpackMain.getFeatures();
const allFeatures = features.getFeatures();
const isRegisteredApp = allFeatures.some(feature => feature.app.includes(appId));
if (isRegisteredApp) {

View file

@ -12,10 +12,10 @@ import {
KibanaRequest,
KibanaResponseFactory,
CoreSetup,
} from '../../../../../../../src/core/server';
} from '../../../../../../src/core/server';
import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server';
import { LegacyAPI } from '../../new_platform/plugin';
import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server';
import { LegacyAPI } from '../../plugin';
describe('onRequestInterceptor', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
@ -110,9 +110,7 @@ describe('onRequestInterceptor', () => {
initSpacesOnRequestInterceptor({
getLegacyAPI: () =>
({
legacyConfig: {
serverBasePath: opts.basePath,
},
legacyConfig: {},
} as LegacyAPI),
http: (http as unknown) as CoreSetup['http'],
});

View file

@ -12,7 +12,7 @@ import {
import { format } from 'url';
import { DEFAULT_SPACE_ID } from '../../../common/constants';
import { modifyUrl } from '../utils/url';
import { LegacyAPI } from '../../new_platform/plugin';
import { LegacyAPI } from '../../plugin';
import { getSpaceIdFromPath } from '../../../common';
export interface OnRequestInterceptorDeps {
@ -25,7 +25,7 @@ export function initSpacesOnRequestInterceptor({ getLegacyAPI, http }: OnRequest
response: LifecycleResponseFactory,
toolkit: OnPreAuthToolkit
) {
const { serverBasePath } = getLegacyAPI().legacyConfig;
const serverBasePath = http.basePath.serverBasePath;
const path = request.url.pathname;
// If navigating within the context of a space, then we store the Space's URL Context on the request,

View file

@ -6,7 +6,7 @@
import { SavedObjectsClientWrapperFactory } from 'src/core/server';
import { SpacesSavedObjectsClient } from './spaces_saved_objects_client';
import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service';
import { SpacesServiceSetup } from '../../spaces_service/spaces_service';
export function spacesSavedObjectsClientWrapperFactory(
spacesService: SpacesServiceSetup,

View file

@ -6,7 +6,7 @@
import { DEFAULT_SPACE_ID } from '../../../common/constants';
import { SpacesSavedObjectsClient } from './spaces_saved_objects_client';
import { spacesServiceMock } from '../../new_platform/spaces_service/spaces_service.mock';
import { spacesServiceMock } from '../../spaces_service/spaces_service.mock';
const types = ['foo', 'bar', 'space'];

View file

@ -14,7 +14,7 @@ import {
SavedObjectsFindOptions,
SavedObjectsUpdateOptions,
} from 'src/core/server';
import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service';
import { SpacesServiceSetup } from '../../spaces_service/spaces_service';
import { spaceIdToNamespace } from '../utils/namespace';
interface SpacesSavedObjectsClientOptions {

View file

@ -0,0 +1,231 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { spaceSchema } from './space_schema';
const defaultProperties = {
id: 'foo',
name: 'foo',
};
describe('#id', () => {
test('is required', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
id: undefined,
})
).toThrowErrorMatchingInlineSnapshot(
`"[id]: expected value of type [string] but got [undefined]"`
);
});
test('allows lowercase a-z, 0-9, "_" and "-"', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
id: 'abcdefghijklmnopqrstuvwxyz0123456789_-',
})
).not.toThrowError();
});
test(`doesn't allow uppercase`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
id: 'Foo',
})
).toThrowErrorMatchingInlineSnapshot(
`"[id]: must be lower case, a-z, 0-9, '_', and '-' are allowed"`
);
});
test(`doesn't allow an empty string`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
id: '',
})
).toThrowErrorMatchingInlineSnapshot(
`"[id]: must be lower case, a-z, 0-9, '_', and '-' are allowed"`
);
});
['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', ',', '.', '/', '?'].forEach(
invalidCharacter => {
test(`doesn't allow ${invalidCharacter}`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
id: `foo-${invalidCharacter}`,
})
).toThrowError();
});
}
);
});
describe('#disabledFeatures', () => {
test('is optional', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
disabledFeatures: undefined,
})
).not.toThrowError();
});
test('defaults to an empty array', () => {
const result = spaceSchema.validate({
...defaultProperties,
disabledFeatures: undefined,
});
expect(result.disabledFeatures).toEqual([]);
});
test('must be an array if provided', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
disabledFeatures: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`"[disabledFeatures]: expected value of type [array] but got [string]"`
);
});
test('allows an array of strings', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
disabledFeatures: ['foo', 'bar'],
})
).not.toThrowError();
});
test('does not allow an array containing non-string elements', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
disabledFeatures: ['foo', true],
})
).toThrowErrorMatchingInlineSnapshot(
`"[disabledFeatures.1]: expected value of type [string] but got [boolean]"`
);
});
});
describe('#color', () => {
test('is optional', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: undefined,
})
).not.toThrowError();
});
test(`doesn't allow an empty string`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '',
})
).toThrowErrorMatchingInlineSnapshot(
`"[color]: must be a 6 digit hex color, starting with a #"`
);
});
test(`allows lower case hex color code`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '#aabbcc',
})
).not.toThrowError();
});
test(`allows upper case hex color code`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '#AABBCC',
})
).not.toThrowError();
});
test(`allows numeric hex color code`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '#123456',
})
).not.toThrowError();
});
test(`must start with a hash`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '123456',
})
).toThrowErrorMatchingInlineSnapshot(
`"[color]: must be a 6 digit hex color, starting with a #"`
);
});
test(`cannot exceed 6 digits following the hash`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '1234567',
})
).toThrowErrorMatchingInlineSnapshot(
`"[color]: must be a 6 digit hex color, starting with a #"`
);
});
test(`cannot be fewer than 6 digits following the hash`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
color: '12345',
})
).toThrowErrorMatchingInlineSnapshot(
`"[color]: must be a 6 digit hex color, starting with a #"`
);
});
});
describe('#imageUrl', () => {
test('is optional', () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
imageUrl: undefined,
})
).not.toThrowError();
});
test(`must start with data:image`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
imageUrl: 'notValid',
})
).toThrowErrorMatchingInlineSnapshot(`"[imageUrl]: must start with 'data:image'"`);
});
test(`checking that a valid image is accepted as imageUrl`, () => {
expect(() =>
spaceSchema.validate({
...defaultProperties,
imageUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==',
})
).not.toThrowError();
});
});

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { MAX_SPACE_INITIALS } from '../../common';
export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/;
export const spaceSchema = schema.object({
id: schema.string({
validate: value => {
if (!SPACE_ID_REGEX.test(value)) {
return `must be lower case, a-z, 0-9, '_', and '-' are allowed`;
}
},
}),
name: schema.string({ minLength: 1 }),
description: schema.maybe(schema.string()),
initials: schema.maybe(schema.string({ maxLength: MAX_SPACE_INITIALS })),
color: schema.maybe(
schema.string({
validate: value => {
if (!/^#[a-zA-Z0-9]{6}$/.test(value)) {
return `must be a 6 digit hex color, starting with a #`;
}
},
})
),
disabledFeatures: schema.arrayOf(schema.string(), { defaultValue: [] }),
_reserved: schema.maybe(schema.boolean()),
imageUrl: schema.maybe(
schema.string({
validate: value => {
if (value !== '' && !/^data:image.*$/.test(value)) {
return `must start with 'data:image'`;
}
},
})
),
});

View file

@ -29,7 +29,7 @@ const createSpacesClientMock = () =>
create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)),
update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)),
delete: jest.fn(),
} as unknown) as SpacesClient);
} as unknown) as jest.Mocked<SpacesClient>);
export const spacesClientMock = {
create: createSpacesClientMock,

View file

@ -5,9 +5,9 @@
*/
import { SpacesClient } from './spaces_client';
import { AuthorizationService } from '../../../../security/server/lib/authorization/service';
import { actionsFactory } from '../../../../security/server/lib/authorization/actions';
import { SpacesConfigType, config } from '../../new_platform/config';
import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service';
import { actionsFactory } from '../../../../../legacy/plugins/security/server/lib/authorization/actions';
import { ConfigType, ConfigSchema } from '../../config';
import { GetSpacePurpose } from '../../../common/model/types';
const createMockAuditLogger = () => {
@ -69,8 +69,8 @@ const createMockAuthorization = () => {
};
};
const createMockConfig = (mockConfig: SpacesConfigType = { maxSpaces: 1000 }) => {
return config.schema.validate(mockConfig);
const createMockConfig = (mockConfig: ConfigType = { maxSpaces: 1000, enabled: true }) => {
return ConfigSchema.validate(mockConfig);
};
describe('#getAll', () => {
@ -123,6 +123,7 @@ describe('#getAll', () => {
const maxSpaces = 1234;
const mockConfig = createMockConfig({
maxSpaces: 1234,
enabled: true,
});
const client = new SpacesClient(
@ -162,6 +163,7 @@ describe('#getAll', () => {
const maxSpaces = 1234;
const mockConfig = createMockConfig({
maxSpaces: 1234,
enabled: true,
});
const request = Symbol() as any;
@ -287,6 +289,7 @@ describe('#getAll', () => {
const maxSpaces = 1234;
const mockConfig = createMockConfig({
maxSpaces: 1234,
enabled: true,
});
const mockInternalRepository = {
find: jest.fn().mockReturnValue({
@ -355,6 +358,7 @@ describe('#getAll', () => {
const maxSpaces = 1234;
const mockConfig = createMockConfig({
maxSpaces: 1234,
enabled: true,
});
const request = Symbol() as any;
@ -725,6 +729,7 @@ describe('#create', () => {
};
const mockConfig = createMockConfig({
maxSpaces,
enabled: true,
});
const request = Symbol() as any;
@ -766,6 +771,7 @@ describe('#create', () => {
};
const mockConfig = createMockConfig({
maxSpaces,
enabled: true,
});
const request = Symbol() as any;
@ -807,6 +813,7 @@ describe('#create', () => {
};
const mockConfig = createMockConfig({
maxSpaces,
enabled: true,
});
const request = Symbol() as any;
@ -850,6 +857,7 @@ describe('#create', () => {
};
const mockConfig = createMockConfig({
maxSpaces,
enabled: true,
});
const request = Symbol() as any;
@ -931,6 +939,7 @@ describe('#create', () => {
};
const mockConfig = createMockConfig({
maxSpaces,
enabled: true,
});
const request = Symbol() as any;
@ -983,6 +992,7 @@ describe('#create', () => {
};
const mockConfig = createMockConfig({
maxSpaces,
enabled: true,
});
const request = Symbol() as any;

View file

@ -7,11 +7,11 @@ import Boom from 'boom';
import { omit } from 'lodash';
import { Legacy } from 'kibana';
import { KibanaRequest } from 'src/core/server';
import { AuthorizationService } from '../../../../security/server/lib/authorization/service';
import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service';
import { isReservedSpace } from '../../../common/is_reserved_space';
import { Space } from '../../../common/model/space';
import { SpacesAuditLogger } from '../audit_logger';
import { SpacesConfigType } from '../../new_platform/config';
import { ConfigType } from '../../config';
import { GetSpacePurpose } from '../../../common/model/types';
type SpacesClientRequestFacade = Legacy.Request | KibanaRequest;
@ -33,7 +33,7 @@ export class SpacesClient {
private readonly debugLogger: (message: string) => void,
private readonly authorization: AuthorizationService | null,
private readonly callWithRequestSavedObjectRepository: any,
private readonly config: SpacesConfigType,
private readonly config: ConfigType,
private readonly internalSavedObjectRepository: any,
private readonly request: SpacesClientRequestFacade
) {}

View file

@ -7,13 +7,14 @@
import * as Rx from 'rxjs';
import { DEFAULT_SPACE_ID } from '../../common/constants';
import { createSpacesTutorialContextFactory } from './spaces_tutorial_context_factory';
import { SpacesService } from '../new_platform/spaces_service';
import { SpacesService } from '../spaces_service';
import { SavedObjectsLegacyService } from 'src/core/server';
import { SpacesAuditLogger } from './audit_logger';
import { elasticsearchServiceMock, coreMock } from '../../../../../../src/core/server/mocks';
import { spacesServiceMock } from '../new_platform/spaces_service/spaces_service.mock';
import { createOptionalPlugin } from '../../../../server/lib/optional_plugin';
import { LegacyAPI } from '../new_platform/plugin';
import { elasticsearchServiceMock, coreMock } from '../../../../../src/core/server/mocks';
import { spacesServiceMock } from '../spaces_service/spaces_service.mock';
import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin';
import { LegacyAPI } from '../plugin';
import { spacesConfig } from './__fixtures__';
const log = {
log: jest.fn(),
@ -26,9 +27,7 @@ const log = {
};
const legacyAPI: LegacyAPI = {
legacyConfig: {
serverBasePath: '/foo',
},
legacyConfig: {},
savedObjects: {} as SavedObjectsLegacyService,
} as LegacyAPI;
@ -56,9 +55,10 @@ describe('createSpacesTutorialContextFactory', () => {
const spacesService = await service.setup({
http: coreMock.createSetup().http,
elasticsearch: elasticsearchServiceMock.createSetupContract(),
security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'),
getSecurity: () =>
createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'),
getSpacesAuditLogger: () => ({} as SpacesAuditLogger),
config$: Rx.of({ maxSpaces: 1000 }),
config$: Rx.of(spacesConfig),
});
const contextFactory = createSpacesTutorialContextFactory(spacesService);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SpacesServiceSetup } from '../new_platform/spaces_service/spaces_service';
import { SpacesServiceSetup } from '../spaces_service/spaces_service';
export function createSpacesTutorialContextFactory(spacesService: SpacesServiceSetup) {
return function spacesTutorialContextFactory(request: any) {

View file

@ -5,7 +5,7 @@
*/
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../../../plugins/features/server';
import { Feature } from '../../../../plugins/features/server';
import { Space } from '../../common/model/space';
import { toggleUICapabilities } from './toggle_ui_capabilities';

View file

@ -5,7 +5,7 @@
*/
import _ from 'lodash';
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../../../plugins/features/server';
import { Feature } from '../../../../plugins/features/server';
import { Space } from '../../common/model/space';
export function toggleUICapabilities(

View file

@ -5,30 +5,33 @@
*/
import { Observable } from 'rxjs';
import { SavedObjectsLegacyService, CoreSetup } from 'src/core/server';
import { Logger, PluginInitializerContext } from 'src/core/server';
import { take } from 'rxjs/operators';
import { CapabilitiesModifier } from 'src/legacy/server/capabilities';
import { Legacy } from 'kibana';
import { OptionalPlugin } from '../../../../server/lib/optional_plugin';
import { XPackMainPlugin } from '../../../xpack_main/xpack_main';
import { createDefaultSpace } from '../lib/create_default_space';
import {
SavedObjectsLegacyService,
CoreSetup,
KibanaRequest,
Logger,
PluginInitializerContext,
} from '../../../../src/core/server';
import { SecurityPlugin } from '../../../legacy/plugins/security';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
import { LicensingPluginSetup } from '../../licensing/server';
import { OptionalPlugin } from '../../../legacy/server/lib/optional_plugin';
import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/xpack_main';
import { createDefaultSpace } from './lib/create_default_space';
// @ts-ignore
import { AuditLogger } from '../../../../server/lib/audit_logger';
// @ts-ignore
import { watchStatusAndLicenseToInitialize } from '../../../../server/lib/watch_status_and_license_to_initialize';
import { checkLicense } from '../lib/check_license';
import { spacesSavedObjectsClientWrapperFactory } from '../lib/saved_objects_client/saved_objects_client_wrapper_factory';
import { SpacesAuditLogger } from '../lib/audit_logger';
import { createSpacesTutorialContextFactory } from '../lib/spaces_tutorial_context_factory';
import { initExternalSpacesApi } from '../routes/api/external';
import { getSpacesUsageCollector } from '../lib/get_spaces_usage_collector';
import { spacesSavedObjectsClientWrapperFactory } from './lib/saved_objects_client/saved_objects_client_wrapper_factory';
import { SpacesAuditLogger } from './lib/audit_logger';
import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory';
import { getSpacesUsageCollector } from './lib/get_spaces_usage_collector';
import { SpacesService } from './spaces_service';
import { SecurityPlugin } from '../../../security';
import { SpacesServiceSetup } from './spaces_service/spaces_service';
import { SpacesConfigType } from './config';
import { getActiveSpace } from '../lib/get_active_space';
import { toggleUICapabilities } from '../lib/toggle_ui_capabilities';
import { initSpacesRequestInterceptors } from '../lib/request_interceptors';
import { ConfigType } from './config';
import { toggleUICapabilities } from './lib/toggle_ui_capabilities';
import { initSpacesRequestInterceptors } from './lib/request_interceptors';
import { initExternalSpacesApi } from './routes/api/external';
/**
* Describes a set of APIs that is available in the legacy platform only and required by this plugin
@ -52,30 +55,33 @@ export interface LegacyAPI {
};
legacyConfig: {
kibanaIndex: string;
serverBasePath: string;
serverDefaultRoute: string;
};
router: Legacy.Server['route'];
}
export interface PluginsSetup {
xpackMain: XPackMainPlugin;
// TODO: Spaces has a circular dependency with Security right now.
// Security is not yet available when init runs, so this is wrapped in an optional plugin for the time being.
security: OptionalPlugin<SecurityPlugin>;
xpackMain: XPackMainPlugin;
// TODO: this is temporary for `watchLicenseAndStatusToInitialize`
spaces: any;
}
export interface PluginsSetup {
features: FeaturesPluginSetup;
licensing: LicensingPluginSetup;
}
export interface SpacesPluginSetup {
spacesService: SpacesServiceSetup;
registerLegacyAPI: (legacyAPI: LegacyAPI) => void;
__legacyCompat: {
registerLegacyAPI: (legacyAPI: LegacyAPI) => void;
// TODO: We currently need the legacy plugin to inform this plugin when it is safe to create the default space.
// The NP does not have the equivilent ES connection/health/comapt checks that the legacy world does.
// See: https://github.com/elastic/kibana/issues/43456
createDefaultSpace: () => Promise<void>;
};
}
export class Plugin {
private readonly pluginId = 'spaces';
private readonly config$: Observable<SpacesConfigType>;
private readonly config$: Observable<ConfigType>;
private readonly log: Logger;
@ -98,91 +104,29 @@ export class Plugin {
};
constructor(initializerContext: PluginInitializerContext) {
this.config$ = initializerContext.config.create<SpacesConfigType>();
this.config$ = initializerContext.config.create<ConfigType>();
this.log = initializerContext.logger.get();
}
public async start() {}
public async setup(core: CoreSetup, plugins: PluginsSetup): Promise<SpacesPluginSetup> {
const xpackMainPlugin: XPackMainPlugin = plugins.xpackMain;
watchStatusAndLicenseToInitialize(xpackMainPlugin, plugins.spaces, async () => {
await createDefaultSpace({
elasticsearch: core.elasticsearch,
savedObjects: this.getLegacyAPI().savedObjects,
});
});
// Register a function that is called whenever the xpack info changes,
// to re-compute the license check results for this plugin.
xpackMainPlugin.info.feature(this.pluginId).registerLicenseCheckResultsGenerator(checkLicense);
const service = new SpacesService(this.log, this.getLegacyAPI);
const spacesService = await service.setup({
http: core.http,
elasticsearch: core.elasticsearch,
security: plugins.security,
getSecurity: () => this.getLegacyAPI().security,
getSpacesAuditLogger: this.getSpacesAuditLogger,
config$: this.config$,
});
return {
spacesService,
registerLegacyAPI: (legacyAPI: LegacyAPI) => {
this.legacyAPI = legacyAPI;
this.setupLegacyComponents(core, spacesService, plugins.xpackMain);
},
};
}
private setupLegacyComponents(
core: CoreSetup,
spacesService: SpacesServiceSetup,
xpackMainPlugin: XPackMainPlugin
) {
const legacyAPI = this.getLegacyAPI();
const { addScopedSavedObjectsClientWrapperFactory, types } = legacyAPI.savedObjects;
addScopedSavedObjectsClientWrapperFactory(
Number.MIN_SAFE_INTEGER,
'spaces',
spacesSavedObjectsClientWrapperFactory(spacesService, types)
);
legacyAPI.tutorial.addScopedTutorialContextFactory(
createSpacesTutorialContextFactory(spacesService)
);
legacyAPI.capabilities.registerCapabilitiesModifier(async (request, uiCapabilities) => {
const spacesClient = await spacesService.scopedClient(request);
try {
const activeSpace = await getActiveSpace(
spacesClient,
core.http.basePath.get(request),
legacyAPI.legacyConfig.serverBasePath
);
const features = xpackMainPlugin.getFeatures();
return toggleUICapabilities(features, uiCapabilities, activeSpace);
} catch (e) {
return uiCapabilities;
}
});
// Register a function with server to manage the collection of usage stats
legacyAPI.usage.collectorSet.register(
getSpacesUsageCollector({
kibanaIndex: legacyAPI.legacyConfig.kibanaIndex,
usage: legacyAPI.usage,
xpackMain: xpackMainPlugin,
})
);
const externalRouter = core.http.createRouter();
initExternalSpacesApi({
legacyRouter: legacyAPI.router,
externalRouter,
log: this.log,
savedObjects: legacyAPI.savedObjects,
getSavedObjects: () => this.getLegacyAPI().savedObjects,
spacesService,
xpackMain: xpackMainPlugin,
});
initSpacesRequestInterceptors({
@ -190,7 +134,61 @@ export class Plugin {
log: this.log,
getLegacyAPI: this.getLegacyAPI,
spacesService,
xpackMain: xpackMainPlugin,
features: plugins.features,
});
return {
spacesService,
__legacyCompat: {
registerLegacyAPI: (legacyAPI: LegacyAPI) => {
this.legacyAPI = legacyAPI;
this.setupLegacyComponents(spacesService, plugins.features, plugins.licensing);
},
createDefaultSpace: async () => {
const esClient = await core.elasticsearch.adminClient$.pipe(take(1)).toPromise();
return createDefaultSpace({
esClient,
savedObjects: this.getLegacyAPI().savedObjects,
});
},
},
};
}
public stop() {}
private setupLegacyComponents(
spacesService: SpacesServiceSetup,
featuresSetup: FeaturesPluginSetup,
licensingSetup: LicensingPluginSetup
) {
const legacyAPI = this.getLegacyAPI();
const { addScopedSavedObjectsClientWrapperFactory, types } = legacyAPI.savedObjects;
addScopedSavedObjectsClientWrapperFactory(
Number.MIN_SAFE_INTEGER,
'spaces',
spacesSavedObjectsClientWrapperFactory(spacesService, types)
);
legacyAPI.tutorial.addScopedTutorialContextFactory(
createSpacesTutorialContextFactory(spacesService)
);
legacyAPI.capabilities.registerCapabilitiesModifier(async (request, uiCapabilities) => {
try {
const activeSpace = await spacesService.getActiveSpace(KibanaRequest.from(request));
const features = featuresSetup.getFeatures();
return toggleUICapabilities(features, uiCapabilities, activeSpace);
} catch (e) {
return uiCapabilities;
}
});
// Register a function with server to manage the collection of usage stats
legacyAPI.usage.collectorSet.register(
getSpacesUsageCollector({
kibanaIndex: legacyAPI.legacyConfig.kibanaIndex,
usage: legacyAPI.usage,
features: featuresSetup,
licensing: licensingSetup,
})
);
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Readable } from 'stream';
import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams';
import { SavedObjectsSchema, SavedObjectsLegacyService } from 'src/core/server';
import { LegacyAPI } from '../../../plugin';
import { Space } from '../../../../common/model/space';
import { createSpaces } from '.';
async function readStreamToCompletion(stream: Readable) {
return (await (createPromiseFromStreams([stream, createConcatStream([])]) as unknown)) as any[];
}
interface LegacyAPIOpts {
spaces?: Space[];
}
export const createLegacyAPI = ({
spaces = createSpaces().map(s => ({ id: s.id, ...s.attributes })),
}: LegacyAPIOpts = {}) => {
const mockSavedObjectsClientContract = {
get: jest.fn((type, id) => {
const result = spaces.filter(s => s.id === id);
if (!result.length) {
throw new Error(`not found: [${type}:${id}]`);
}
return result[0];
}),
find: jest.fn(() => {
return {
total: spaces.length,
saved_objects: spaces,
};
}),
create: jest.fn((type, attributes, { id }) => {
if (spaces.find(s => s.id === id)) {
throw new Error('conflict');
}
return {};
}),
update: jest.fn((type, id) => {
if (!spaces.find(s => s.id === id)) {
throw new Error('not found: during update');
}
return {};
}),
delete: jest.fn((type: string, id: string) => {
return {};
}),
deleteByNamespace: jest.fn(),
};
const savedObjectsService = ({
types: ['visualization', 'dashboard', 'index-pattern', 'globalType'],
schema: new SavedObjectsSchema({
space: {
isNamespaceAgnostic: true,
hidden: true,
},
globalType: {
isNamespaceAgnostic: true,
},
}),
getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract),
importExport: {
objectLimit: 10000,
getSortedObjectsForExport: jest.fn().mockResolvedValue(
new Readable({
objectMode: true,
read() {
this.push(null);
},
})
),
importSavedObjects: jest.fn().mockImplementation(async (opts: Record<string, any>) => {
const objectsToImport: any[] = await readStreamToCompletion(opts.readStream);
return {
success: true,
successCount: objectsToImport.length,
};
}),
resolveImportErrors: jest.fn().mockImplementation(async (opts: Record<string, any>) => {
const objectsToImport: any[] = await readStreamToCompletion(opts.readStream);
return {
success: true,
successCount: objectsToImport.length,
};
}),
},
SavedObjectsClient: {
errors: {
isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')),
isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')),
},
},
} as unknown) as jest.Mocked<SavedObjectsLegacyService>;
const legacyAPI: jest.Mocked<LegacyAPI> = {
legacyConfig: {
kibanaIndex: '',
},
auditLogger: {} as any,
capabilities: {} as any,
security: {} as any,
tutorial: {} as any,
usage: {} as any,
xpackMain: {} as any,
savedObjects: savedObjectsService,
};
return legacyAPI;
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server';
export const createMockSavedObjectsRepository = (spaces: any[] = []) => {
const mockSavedObjectsClientContract = ({
get: jest.fn((type, id) => {
const result = spaces.filter(s => s.id === id);
if (!result.length) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
return result[0];
}),
find: jest.fn(() => {
return {
total: spaces.length,
saved_objects: spaces,
};
}),
create: jest.fn((type, attributes, { id }) => {
if (spaces.find(s => s.id === id)) {
throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict');
}
return {};
}),
update: jest.fn((type, id) => {
if (!spaces.find(s => s.id === id)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
return {};
}),
delete: jest.fn((type: string, id: string) => {
return {};
}),
deleteByNamespace: jest.fn(),
} as unknown) as jest.Mocked<SavedObjectsClientContract>;
return mockSavedObjectsClientContract;
};

View file

@ -10,18 +10,21 @@ export function createSpaces() {
id: 'a-space',
attributes: {
name: 'a space',
disabledFeatures: [],
},
},
{
id: 'b-space',
attributes: {
name: 'b space',
disabledFeatures: [],
},
},
{
id: 'default',
attributes: {
name: 'Default Space',
disabledFeatures: [],
_reserved: true,
},
},

View file

@ -5,11 +5,6 @@
*/
export { createSpaces } from './create_spaces';
export {
createTestHandler,
TestConfig,
TestOptions,
TeardownFn,
RequestRunner,
RequestRunnerResult,
} from './create_test_handler';
export { createLegacyAPI } from './create_legacy_api';
export { createMockSavedObjectsRepository } from './create_mock_so_repository';
export { mockRouteContext, mockRouteContextWithInvalidLicense } from './route_contexts';

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RequestHandlerContext } from 'src/core/server';
import { LICENSE_STATUS } from '../../../../../licensing/server/constants';
export const mockRouteContext = ({
licensing: {
license: {
check: jest.fn().mockReturnValue({
check: LICENSE_STATUS.Valid,
}),
},
},
} as unknown) as RequestHandlerContext;
export const mockRouteContextWithInvalidLicense = ({
licensing: {
license: {
check: jest.fn().mockReturnValue({
check: LICENSE_STATUS.Invalid,
message: 'License is invalid for spaces',
}),
},
},
} as unknown) as RequestHandlerContext;

View file

@ -0,0 +1,450 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
import {
createSpaces,
createLegacyAPI,
createMockSavedObjectsRepository,
mockRouteContext,
mockRouteContextWithInvalidLicense,
} from '../__fixtures__';
import { CoreSetup, IRouter, kibanaResponseFactory } from 'src/core/server';
import {
loggingServiceMock,
elasticsearchServiceMock,
httpServiceMock,
httpServerMock,
} from 'src/core/server/mocks';
import { SpacesService } from '../../../spaces_service';
import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin';
import { SpacesAuditLogger } from '../../../lib/audit_logger';
import { SpacesClient } from '../../../lib/spaces_client';
import { initCopyToSpacesApi } from './copy_to_space';
import { ObjectType } from '@kbn/config-schema';
import { RouteSchemas } from 'src/core/server/http/router/route';
import { spacesConfig } from '../../../lib/__fixtures__';
describe('copy to space', () => {
const spacesSavedObjects = createSpaces();
const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes }));
const setup = async () => {
const httpService = httpServiceMock.createSetupContract();
const router = httpService.createRouter('') as jest.Mocked<IRouter>;
const legacyAPI = createLegacyAPI({ spaces });
const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects);
const log = loggingServiceMock.create().get('spaces');
const service = new SpacesService(log, () => legacyAPI);
const spacesService = await service.setup({
http: (httpService as unknown) as CoreSetup['http'],
elasticsearch: elasticsearchServiceMock.createSetupContract(),
getSecurity: () =>
createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'),
getSpacesAuditLogger: () => ({} as SpacesAuditLogger),
config$: Rx.of(spacesConfig),
});
spacesService.scopedClient = jest.fn((req: any) => {
return Promise.resolve(
new SpacesClient(
null as any,
() => null,
null,
savedObjectsRepositoryMock,
spacesConfig,
savedObjectsRepositoryMock,
req
)
);
});
initCopyToSpacesApi({
externalRouter: router,
getSavedObjects: () => legacyAPI.savedObjects,
log,
spacesService,
});
const [
[ctsRouteDefinition, ctsRouteHandler],
[resolveRouteDefinition, resolveRouteHandler],
] = router.post.mock.calls;
return {
copyToSpace: {
routeValidation: ctsRouteDefinition.validate as RouteSchemas<
ObjectType,
ObjectType,
ObjectType
>,
routeHandler: ctsRouteHandler,
},
resolveConflicts: {
routeValidation: resolveRouteDefinition.validate as RouteSchemas<
ObjectType,
ObjectType,
ObjectType
>,
routeHandler: resolveRouteHandler,
},
savedObjectsRepositoryMock,
legacyAPI,
};
};
describe('POST /api/spaces/_copy_saved_objects', () => {
it(`returns http/403 when the license is invalid`, async () => {
const { copyToSpace } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'post',
});
const response = await copyToSpace.routeHandler(
mockRouteContextWithInvalidLicense,
request,
kibanaResponseFactory
);
expect(response.status).toEqual(403);
expect(response.payload).toEqual({
message: 'License is invalid for spaces',
});
});
it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => {
const payload = {
spaces: ['a-space'],
objects: [],
};
const { copyToSpace, legacyAPI } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
await copyToSpace.routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(legacyAPI.savedObjects.getScopedSavedObjectsClient).toHaveBeenCalledWith(
expect.any(Object),
{
excludedWrappers: ['spaces'],
}
);
});
it(`requires space IDs to be unique`, async () => {
const payload = {
spaces: ['a-space', 'a-space'],
objects: [],
};
const { copyToSpace } = await setup();
expect(() =>
copyToSpace.routeValidation.body!.validate(payload)
).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`);
});
it(`requires well-formed space IDS`, async () => {
const payload = {
spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'],
objects: [],
};
const { copyToSpace } = await setup();
expect(() =>
copyToSpace.routeValidation.body!.validate(payload)
).toThrowErrorMatchingInlineSnapshot(
`"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed"`
);
});
it(`requires objects to be unique`, async () => {
const payload = {
spaces: ['a-space'],
objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }],
};
const { copyToSpace } = await setup();
expect(() =>
copyToSpace.routeValidation.body!.validate(payload)
).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`);
});
it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => {
const payload = {
spaces: ['a-space'],
objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }],
};
const { copyToSpace, legacyAPI } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await copyToSpace.routeHandler(
mockRouteContext,
request,
kibanaResponseFactory
);
const { status } = response;
expect(status).toEqual(200);
expect(legacyAPI.savedObjects.importExport.importSavedObjects).toHaveBeenCalledTimes(1);
const [importCallOptions] = (legacyAPI.savedObjects.importExport
.importSavedObjects as any).mock.calls[0];
expect(importCallOptions).toMatchObject({
namespace: 'a-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
});
it('copies to multiple spaces', async () => {
const payload = {
spaces: ['a-space', 'b-space'],
objects: [{ type: 'visualization', id: 'bar' }],
};
const { copyToSpace, legacyAPI } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await copyToSpace.routeHandler(
mockRouteContext,
request,
kibanaResponseFactory
);
const { status } = response;
expect(status).toEqual(200);
expect(legacyAPI.savedObjects.importExport.importSavedObjects).toHaveBeenCalledTimes(2);
const [firstImportCallOptions] = (legacyAPI.savedObjects.importExport
.importSavedObjects as any).mock.calls[0];
expect(firstImportCallOptions).toMatchObject({
namespace: 'a-space',
});
const [secondImportCallOptions] = (legacyAPI.savedObjects.importExport
.importSavedObjects as any).mock.calls[1];
expect(secondImportCallOptions).toMatchObject({
namespace: 'b-space',
});
});
});
describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => {
it(`returns http/403 when the license is invalid`, async () => {
const { resolveConflicts } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'post',
});
const response = await resolveConflicts.routeHandler(
mockRouteContextWithInvalidLicense,
request,
kibanaResponseFactory
);
expect(response.status).toEqual(403);
expect(response.payload).toEqual({
message: 'License is invalid for spaces',
});
});
it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => {
const payload = {
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
],
},
objects: [{ type: 'visualization', id: 'bar' }],
};
const { resolveConflicts, legacyAPI } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
await resolveConflicts.routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(legacyAPI.savedObjects.getScopedSavedObjectsClient).toHaveBeenCalledWith(
expect.any(Object),
{
excludedWrappers: ['spaces'],
}
);
});
it(`requires objects to be unique`, async () => {
const payload = {
retries: {},
objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }],
};
const { resolveConflicts } = await setup();
expect(() =>
resolveConflicts.routeValidation.body!.validate(payload)
).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`);
});
it(`requires well-formed space ids`, async () => {
const payload = {
retries: {
['invalid-space-id!@#$%^&*()']: [
{
type: 'foo',
id: 'bar',
overwrite: true,
},
],
},
objects: [{ type: 'foo', id: 'bar' }],
};
const { resolveConflicts } = await setup();
expect(() =>
resolveConflicts.routeValidation.body!.validate(payload)
).toThrowErrorMatchingInlineSnapshot(
`"[key(\\"invalid-space-id!@#$%^&*()\\")]: Invalid space id: invalid-space-id!@#$%^&*()"`
);
});
it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => {
const payload = {
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
{
type: 'globalType',
id: 'bar',
overwrite: true,
},
],
},
objects: [
{
type: 'globalType',
id: 'bar',
},
{ type: 'visualization', id: 'bar' },
],
};
const { resolveConflicts, legacyAPI } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await resolveConflicts.routeHandler(
mockRouteContext,
request,
kibanaResponseFactory
);
const { status } = response;
expect(status).toEqual(200);
expect(legacyAPI.savedObjects.importExport.resolveImportErrors).toHaveBeenCalledTimes(1);
const [resolveImportErrorsCallOptions] = (legacyAPI.savedObjects.importExport
.resolveImportErrors as any).mock.calls[0];
expect(resolveImportErrorsCallOptions).toMatchObject({
namespace: 'a-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
});
it('resolves conflicts for multiple spaces', async () => {
const payload = {
objects: [{ type: 'visualization', id: 'bar' }],
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
],
['b-space']: [
{
type: 'globalType',
id: 'bar',
overwrite: true,
},
],
},
};
const { resolveConflicts, legacyAPI } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await resolveConflicts.routeHandler(
mockRouteContext,
request,
kibanaResponseFactory
);
const { status } = response;
expect(status).toEqual(200);
expect(legacyAPI.savedObjects.importExport.resolveImportErrors).toHaveBeenCalledTimes(2);
const [resolveImportErrorsFirstCallOptions] = (legacyAPI.savedObjects.importExport
.resolveImportErrors as any).mock.calls[0];
expect(resolveImportErrorsFirstCallOptions).toMatchObject({
namespace: 'a-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
const [resolveImportErrorsSecondCallOptions] = (legacyAPI.savedObjects.importExport
.resolveImportErrors as any).mock.calls[1];
expect(resolveImportErrorsSecondCallOptions).toMatchObject({
namespace: 'b-space',
supportedTypes: ['visualization', 'dashboard', 'index-pattern'],
});
});
});
});

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import _ from 'lodash';
import { SavedObject } from 'src/core/server';
import {
copySavedObjectsToSpacesFactory,
resolveCopySavedObjectsToSpacesConflictsFactory,
} from '../../../lib/copy_to_spaces';
import { ExternalRouteDeps } from '.';
import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces';
import { SPACE_ID_REGEX } from '../../../lib/space_schema';
import { createLicensedRouteHandler } from '../../lib';
type SavedObjectIdentifier = Pick<SavedObject, 'id' | 'type'>;
const areObjectsUnique = (objects: SavedObjectIdentifier[]) =>
_.uniq(objects, (o: SavedObjectIdentifier) => `${o.type}:${o.id}`).length === objects.length;
export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
const { externalRouter, spacesService, getSavedObjects } = deps;
externalRouter.post(
{
path: '/api/spaces/_copy_saved_objects',
options: {
tags: ['access:copySavedObjectsToSpaces'],
},
validate: {
body: schema.object({
spaces: schema.arrayOf(
schema.string({
validate: value => {
if (!SPACE_ID_REGEX.test(value)) {
return `lower case, a-z, 0-9, "_", and "-" are allowed`;
}
},
}),
{
validate: spaceIds => {
if (_.uniq(spaceIds).length !== spaceIds.length) {
return 'duplicate space ids are not allowed';
}
},
}
),
objects: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
}),
{
validate: objects => {
if (!areObjectsUnique(objects)) {
return 'duplicate objects are not allowed';
}
},
}
),
includeReferences: schema.boolean({ defaultValue: false }),
overwrite: schema.boolean({ defaultValue: false }),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
const savedObjectsClient = getSavedObjects().getScopedSavedObjectsClient(
request,
COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS
);
const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(
savedObjectsClient,
getSavedObjects()
);
const { spaces: destinationSpaceIds, objects, includeReferences, overwrite } = request.body;
const sourceSpaceId = spacesService.getSpaceId(request);
const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, {
objects,
includeReferences,
overwrite,
});
return response.ok({ body: copyResponse });
})
);
externalRouter.post(
{
path: '/api/spaces/_resolve_copy_saved_objects_errors',
options: {
tags: ['access:copySavedObjectsToSpaces'],
},
validate: {
body: schema.object({
retries: schema.recordOf(
schema.string({
validate: spaceId => {
if (!SPACE_ID_REGEX.test(spaceId)) {
return `Invalid space id: ${spaceId}`;
}
},
}),
schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
overwrite: schema.boolean({ defaultValue: false }),
})
)
),
objects: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
}),
{
validate: objects => {
if (!areObjectsUnique(objects)) {
return 'duplicate objects are not allowed';
}
},
}
),
includeReferences: schema.boolean({ defaultValue: false }),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
const savedObjectsClient = getSavedObjects().getScopedSavedObjectsClient(
request,
COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS
);
const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory(
savedObjectsClient,
getSavedObjects()
);
const { objects, includeReferences, retries } = request.body;
const sourceSpaceId = spacesService.getSpaceId(request);
const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts(
sourceSpaceId,
{
objects,
includeReferences,
retries,
}
);
return response.ok({ body: resolveConflictsResponse });
})
);
}

Some files were not shown because too many files have changed in this diff Show more