diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 4ff56536e3867..e6ab4df7a6d88 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -31,7 +31,7 @@ const executeParams = { request: {} as KibanaRequest, }; -const spacesMock = spacesServiceMock.createSetupContract(); +const spacesMock = spacesServiceMock.createStartContract(); const loggerMock = loggingSystemMock.create().get(); const getActionsClientWithRequest = jest.fn(); actionExecutor.initialize({ diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index af70fbf2ec896..8953a1cc5fb0d 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -15,7 +15,7 @@ import { ProxySettings, } from '../types'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; -import { SpacesServiceSetup } from '../../../spaces/server'; +import { SpacesServiceStart } from '../../../spaces/server'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { ActionsClient } from '../actions_client'; @@ -23,7 +23,7 @@ import { ActionExecutionSource } from './action_execution_source'; export interface ActionExecutorContext { logger: Logger; - spaces?: SpacesServiceSetup; + spaces?: SpacesServiceStart; getServices: GetServicesFunction; getActionsClientWithRequest: ( request: KibanaRequest, diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 18cbd9f9c5fad..136ca5cb98465 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -12,7 +12,7 @@ import { TaskRunnerFactory } from './task_runner_factory'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { actionExecutorMock } from './action_executor.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { savedObjectsClientMock, loggingSystemMock } from 'src/core/server/mocks'; +import { savedObjectsClientMock, loggingSystemMock, httpServiceMock } from 'src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { ActionTypeDisabledError } from './errors'; import { actionsClientMock } from '../mocks'; @@ -70,7 +70,7 @@ const taskRunnerFactoryInitializerParams = { actionTypeRegistry, logger: loggingSystemMock.create().get(), encryptedSavedObjectsClient: mockedEncryptedSavedObjectsClient, - getBasePath: jest.fn().mockReturnValue(undefined), + basePathService: httpServiceMock.createBasePath(), getUnsecuredSavedObjectsClient: jest.fn().mockReturnValue(services.savedObjectsClient), }; @@ -126,27 +126,23 @@ test('executes the task by calling the executor with proper parameters', async ( expect( mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser ).toHaveBeenCalledWith('action_task_params', '3', { namespace: 'namespace-test' }); + expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, - request: { - getBasePath: expect.any(Function), + request: expect.objectContaining({ headers: { // base64 encoded "123:abc" authorization: 'ApiKey MTIzOmFiYw==', }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }, + }), }); + + const [executeParams] = mockedActionExecutor.execute.mock.calls[0]; + expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + executeParams.request, + '/s/test' + ); }); test('cleans up action_task_params object', async () => { @@ -255,24 +251,19 @@ test('uses API key when provided', async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, - request: { - getBasePath: expect.anything(), + request: expect.objectContaining({ headers: { // base64 encoded "123:abc" authorization: 'ApiKey MTIzOmFiYw==', }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }, + }), }); + + const [executeParams] = mockedActionExecutor.execute.mock.calls[0]; + expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + executeParams.request, + '/s/test' + ); }); test(`doesn't use API key when not provided`, async () => { @@ -297,21 +288,16 @@ test(`doesn't use API key when not provided`, async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, - request: { - getBasePath: expect.anything(), + request: expect.objectContaining({ headers: {}, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }, + }), }); + + const [executeParams] = mockedActionExecutor.execute.mock.calls[0]; + expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + executeParams.request, + '/s/test' + ); }); test(`throws an error when license doesn't support the action type`, async () => { diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index aeeeb4ed7d520..99c8b8b1ff0e1 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -5,14 +5,17 @@ */ import { pick } from 'lodash'; +import type { Request } from '@hapi/hapi'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fromNullable, getOrElse } from 'fp-ts/lib/Option'; +import { addSpaceIdToPath } from '../../../spaces/server'; import { Logger, SavedObjectsClientContract, KibanaRequest, SavedObjectReference, -} from 'src/core/server'; + IBasePath, +} from '../../../../../src/core/server'; import { ActionExecutorContract } from './action_executor'; import { ExecutorError } from './executor_error'; import { RunContext } from '../../../task_manager/server'; @@ -21,7 +24,6 @@ import { ActionTypeDisabledError } from './errors'; import { ActionTaskParams, ActionTypeRegistryContract, - GetBasePathFunction, SpaceIdToNamespaceFunction, ActionTypeExecutorResult, } from '../types'; @@ -33,7 +35,7 @@ export interface TaskRunnerContext { actionTypeRegistry: ActionTypeRegistryContract; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; spaceIdToNamespace: SpaceIdToNamespaceFunction; - getBasePath: GetBasePathFunction; + basePathService: IBasePath; getUnsecuredSavedObjectsClient: (request: KibanaRequest) => SavedObjectsClientContract; } @@ -64,7 +66,7 @@ export class TaskRunnerFactory { logger, encryptedSavedObjectsClient, spaceIdToNamespace, - getBasePath, + basePathService, getUnsecuredSavedObjectsClient, } = this.taskRunnerContext!; @@ -87,11 +89,12 @@ export class TaskRunnerFactory { requestHeaders.authorization = `ApiKey ${apiKey}`; } + const path = addSpaceIdToPath('/', spaceId); + // Since we're using API keys and accessing elasticsearch can only be done // via a request, we're faking one with the proper authorization headers. - const fakeRequest = ({ + const fakeRequest = KibanaRequest.from(({ headers: requestHeaders, - getBasePath: () => getBasePath(spaceId), path: '/', route: { settings: {} }, url: { @@ -102,7 +105,9 @@ export class TaskRunnerFactory { url: '/', }, }, - } as unknown) as KibanaRequest; + } as unknown) as Request); + + basePathService.set(fakeRequest, path); let executorResult: ActionTypeExecutorResult; try { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 9db07f653872f..541f1457eaf69 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -27,7 +27,7 @@ import { } from '../../encrypted_saved_objects/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; -import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server'; +import { SpacesPluginStart } from '../../spaces/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -109,7 +109,6 @@ export interface ActionsPluginsSetup { taskManager: TaskManagerSetupContract; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; licensing: LicensingPluginSetup; - spaces?: SpacesPluginSetup; eventLog: IEventLogService; usageCollection?: UsageCollectionSetup; security?: SecurityPluginSetup; @@ -119,6 +118,7 @@ export interface ActionsPluginsStart { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; taskManager: TaskManagerStartContract; licensing: LicensingPluginStart; + spaces?: SpacesPluginStart; } const includedHiddenTypes = [ @@ -133,12 +133,10 @@ export class ActionsPlugin implements Plugin, Plugi private readonly logger: Logger; private actionsConfig?: ActionsConfig; - private serverBasePath?: string; private taskRunnerFactory?: TaskRunnerFactory; private actionTypeRegistry?: ActionTypeRegistry; private actionExecutor?: ActionExecutor; private licenseState: ILicenseState | null = null; - private spaces?: SpacesServiceSetup; private security?: SecurityPluginSetup; private eventLogService?: IEventLogService; private eventLogger?: IEventLogger; @@ -211,9 +209,7 @@ export class ActionsPlugin implements Plugin, Plugi }); this.taskRunnerFactory = taskRunnerFactory; this.actionTypeRegistry = actionTypeRegistry; - this.serverBasePath = core.http.basePath.serverBasePath; this.actionExecutor = actionExecutor; - this.spaces = plugins.spaces?.spacesService; this.security = plugins.security; registerBuiltInActionTypes({ @@ -339,7 +335,7 @@ export class ActionsPlugin implements Plugin, Plugi actionExecutor!.initialize({ logger, eventLogger: this.eventLogger!, - spaces: this.spaces, + spaces: plugins.spaces?.spacesService, getActionsClientWithRequest, getServices: this.getServicesFactory( getScopedSavedObjectsClientWithoutAccessToActions, @@ -359,12 +355,18 @@ export class ActionsPlugin implements Plugin, Plugi : undefined, }); + const spaceIdToNamespace = (spaceId?: string) => { + return plugins.spaces && spaceId + ? plugins.spaces.spacesService.spaceIdToNamespace(spaceId) + : undefined; + }; + taskRunnerFactory!.initialize({ logger, actionTypeRegistry: actionTypeRegistry!, encryptedSavedObjectsClient, - getBasePath: this.getBasePath, - spaceIdToNamespace: this.spaceIdToNamespace, + basePathService: core.http.basePath, + spaceIdToNamespace, getUnsecuredSavedObjectsClient: (request: KibanaRequest) => this.getUnsecuredSavedObjectsClient(core.savedObjects, request), }); @@ -474,14 +476,6 @@ export class ActionsPlugin implements Plugin, Plugi }; }; - private spaceIdToNamespace = (spaceId?: string): string | undefined => { - return this.spaces && spaceId ? this.spaces.spaceIdToNamespace(spaceId) : undefined; - }; - - private getBasePath = (spaceId?: string): string => { - return this.spaces && spaceId ? this.spaces.getBasePath(spaceId) : this.serverBasePath!; - }; - public stop() { if (this.licenseState) { this.licenseState.clean(); diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 1867815bd5f90..79895195d90f3 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -22,7 +22,6 @@ export { ActionTypeExecutorResult } from '../common'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; export type ActionTypeRegistryContract = PublicMethodsOf; -export type GetBasePathFunction = (spaceId?: string) => string; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; export type ActionTypeConfig = Record; export type ActionTypeSecrets = Record; diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index 62f4b7d5a3fc4..355cdf13ac5eb 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -158,7 +158,6 @@ describe('Alerting Plugin', () => { getActionsClientWithRequest: jest.fn(), getActionsAuthorizationWithRequest: jest.fn(), }, - spaces: () => null, encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), features: mockFeatures(), } as unknown) as AlertingPluginsStart diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 0c91e93938346..811c5a44fbb6c 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -13,7 +13,7 @@ import { EncryptedSavedObjectsPluginStart, } from '../../encrypted_saved_objects/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server'; +import { SpacesPluginStart } from '../../spaces/server'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry } from './alert_type_registry'; import { TaskRunnerFactory } from './task_runner'; @@ -101,7 +101,6 @@ export interface AlertingPluginsSetup { actions: ActionsPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; licensing: LicensingPluginSetup; - spaces?: SpacesPluginSetup; usageCollection?: UsageCollectionSetup; eventLog: IEventLogService; statusService: StatusServiceSetup; @@ -112,6 +111,7 @@ export interface AlertingPluginsStart { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; features: FeaturesPluginStart; eventLog: IEventLogClientService; + spaces?: SpacesPluginStart; } export class AlertingPlugin { @@ -119,10 +119,8 @@ export class AlertingPlugin { private readonly logger: Logger; private alertTypeRegistry?: AlertTypeRegistry; private readonly taskRunnerFactory: TaskRunnerFactory; - private serverBasePath?: string; private licenseState: LicenseState | null = null; private isESOUsingEphemeralEncryptionKey?: boolean; - private spaces?: SpacesServiceSetup; private security?: SecurityPluginSetup; private readonly alertsClientFactory: AlertsClientFactory; private readonly telemetryLogger: Logger; @@ -151,7 +149,6 @@ export class AlertingPlugin { plugins: AlertingPluginsSetup ): Promise { this.licenseState = new LicenseState(plugins.licensing.license$); - this.spaces = plugins.spaces?.spacesService; this.security = plugins.security; core.capabilities.registerProvider(() => { @@ -188,8 +185,6 @@ export class AlertingPlugin { }); this.alertTypeRegistry = alertTypeRegistry; - this.serverBasePath = core.http.basePath.serverBasePath; - const usageCollection = plugins.usageCollection; if (usageCollection) { initializeAlertingTelemetry( @@ -261,7 +256,6 @@ export class AlertingPlugin { public start(core: CoreStart, plugins: AlertingPluginsStart): PluginStartContract { const { - spaces, isESOUsingEphemeralEncryptionKey, logger, taskRunnerFactory, @@ -274,18 +268,24 @@ export class AlertingPlugin { includedHiddenTypes: ['alert'], }); + const spaceIdToNamespace = (spaceId?: string) => { + return plugins.spaces && spaceId + ? plugins.spaces.spacesService.spaceIdToNamespace(spaceId) + : undefined; + }; + alertsClientFactory.initialize({ alertTypeRegistry: alertTypeRegistry!, logger, taskManager: plugins.taskManager, securityPluginSetup: security, encryptedSavedObjectsClient, - spaceIdToNamespace: this.spaceIdToNamespace, + spaceIdToNamespace, getSpaceId(request: KibanaRequest) { - return spaces?.getSpaceId(request); + return plugins.spaces?.spacesService.getSpaceId(request); }, async getSpace(request: KibanaRequest) { - return spaces?.getActiveSpace(request); + return plugins.spaces?.spacesService.getActiveSpace(request); }, actions: plugins.actions, features: plugins.features, @@ -306,10 +306,10 @@ export class AlertingPlugin { logger, getServices: this.getServicesFactory(core.savedObjects, core.elasticsearch), getAlertsClientWithRequest, - spaceIdToNamespace: this.spaceIdToNamespace, + spaceIdToNamespace, actionsPlugin: plugins.actions, encryptedSavedObjectsClient, - getBasePath: this.getBasePath, + basePathService: core.http.basePath, eventLogger: this.eventLogger!, internalSavedObjectsRepository: core.savedObjects.createInternalRepository(['alert']), }); @@ -363,14 +363,6 @@ export class AlertingPlugin { }); } - private spaceIdToNamespace = (spaceId?: string): string | undefined => { - return this.spaces && spaceId ? this.spaces.spaceIdToNamespace(spaceId) : undefined; - }; - - private getBasePath = (spaceId?: string): string => { - return this.spaces && spaceId ? this.spaces.getBasePath(spaceId) : this.serverBasePath!; - }; - private getScopedClientWithAlertSavedObjectType( savedObjects: SavedObjectsServiceStart, request: KibanaRequest diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index bd583159af5d5..07d08f5837d54 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -18,6 +18,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { loggingSystemMock, savedObjectsRepositoryMock, + httpServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -78,7 +79,7 @@ describe('Task Runner', () => { encryptedSavedObjectsClient, logger: loggingSystemMock.create().get(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), - getBasePath: jest.fn().mockReturnValue(undefined), + basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), }; @@ -375,23 +376,24 @@ describe('Task Runner', () => { await taskRunner.run(); expect( taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest - ).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: { - // base64 encoded "123:abc" - authorization: 'ApiKey MTIzOmFiYw==', - }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', + ).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', }, - }, - }); + }) + ); + + const [ + request, + ] = taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mock.calls[0]; + + expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + request, + '/' + ); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -768,23 +770,20 @@ describe('Task Runner', () => { }); await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: { - // base64 encoded "123:abc" - authorization: 'ApiKey MTIzOmFiYw==', - }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', + expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', }, - }, - }); + }) + ); + const [request] = taskRunnerFactoryInitializerParams.getServices.mock.calls[0]; + + expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + request, + '/' + ); }); test(`doesn't use API key when not provided`, async () => { @@ -803,20 +802,18 @@ describe('Task Runner', () => { await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: {}, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); + expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith( + expect.objectContaining({ + headers: {}, + }) + ); + + const [request] = taskRunnerFactoryInitializerParams.getServices.mock.calls[0]; + + expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + request, + '/' + ); }); test('rescheduled the Alert if the schedule has update during a task run', async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 0dad952a86590..24d96788c3395 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -5,6 +5,8 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Dictionary, pickBy, mapValues, without, cloneDeep } from 'lodash'; +import type { Request } from '@hapi/hapi'; +import { addSpaceIdToPath } from '../../../spaces/server'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance, throwUnrecoverableError } from '../../../task_manager/server'; @@ -91,9 +93,10 @@ export class TaskRunner { requestHeaders.authorization = `ApiKey ${apiKey}`; } - return ({ + const path = addSpaceIdToPath('/', spaceId); + + const fakeRequest = KibanaRequest.from(({ headers: requestHeaders, - getBasePath: () => this.context.getBasePath(spaceId), path: '/', route: { settings: {} }, url: { @@ -104,7 +107,11 @@ export class TaskRunner { url: '/', }, }, - } as unknown) as KibanaRequest; + } as unknown) as Request); + + this.context.basePathService.set(fakeRequest, path); + + return fakeRequest; } private getServicesWithSpaceLevelPermissions( diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts index 5da8e4296f4dd..1c10a997d8cdd 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts @@ -11,6 +11,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { loggingSystemMock, savedObjectsRepositoryMock, + httpServiceMock, } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; import { alertsMock, alertsClientMock } from '../mocks'; @@ -64,7 +65,7 @@ describe('Task Runner Factory', () => { encryptedSavedObjectsClient: encryptedSavedObjectsPlugin.getClient(), logger: loggingSystemMock.create().get(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), - getBasePath: jest.fn().mockReturnValue(undefined), + basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), }; diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts index df6f306c6ccc5..2a2d74c1fc259 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Logger, KibanaRequest, ISavedObjectsRepository } from '../../../../../src/core/server'; +import { + Logger, + KibanaRequest, + ISavedObjectsRepository, + IBasePath, +} from '../../../../../src/core/server'; import { RunContext } from '../../../task_manager/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; -import { - AlertType, - GetBasePathFunction, - GetServicesFunction, - SpaceIdToNamespaceFunction, -} from '../types'; +import { AlertType, GetServicesFunction, SpaceIdToNamespaceFunction } from '../types'; import { TaskRunner } from './task_runner'; import { IEventLogger } from '../../../event_log/server'; import { AlertsClient } from '../alerts_client'; @@ -26,7 +26,7 @@ export interface TaskRunnerContext { eventLogger: IEventLogger; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; spaceIdToNamespace: SpaceIdToNamespaceFunction; - getBasePath: GetBasePathFunction; + basePathService: IBasePath; internalSavedObjectsRepository: ISavedObjectsRepository; } diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index dde1628156658..9532d8d1def62 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -32,7 +32,6 @@ import { export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; -export type GetBasePathFunction = (spaceId?: string) => string; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; declare module 'src/core/server' { diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 063c7a6a1fa19..d60ab5c7d37f0 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "requiredPlugins": ["features", "licensing"], "configPath": ["enterpriseSearch"], - "optionalPlugins": ["usageCollection", "security", "home"], + "optionalPlugins": ["usageCollection", "security", "home", "spaces"], "server": true, "ui": true, "requiredBundles": ["home"] diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts index 11d4a387b533f..b9bd111a22ca6 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -10,6 +10,19 @@ jest.mock('./enterprise_search_config_api', () => ({ import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; import { checkAccess } from './check_access'; +import { spacesMock } from '../../../spaces/server/mocks'; + +const enabledSpace = { + id: 'space', + name: 'space', + disabledFeatures: [], +}; + +const disabledSpace = { + id: 'space', + name: 'space', + disabledFeatures: ['enterpriseSearch'], +}; describe('checkAccess', () => { const mockSecurity = { @@ -29,100 +42,156 @@ describe('checkAccess', () => { }, }, }; + const mockSpaces = spacesMock.createStart(); const mockDependencies = { - request: {}, + request: { auth: { isAuthenticated: true } }, config: { host: 'http://localhost:3002' }, security: mockSecurity, + spaces: mockSpaces, } as any; - describe('when security is disabled', () => { - it('should allow all access', async () => { - const security = undefined; - expect(await checkAccess({ ...mockDependencies, security })).toEqual({ - hasAppSearchAccess: true, - hasWorkplaceSearchAccess: true, + describe('when the space is disabled', () => { + it('should deny all access', async () => { + mockSpaces.spacesService.getActiveSpace.mockResolvedValueOnce(disabledSpace); + expect(await checkAccess({ ...mockDependencies })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, }); }); }); - describe('when the user is a superuser', () => { - it('should allow all access', async () => { - const security = { - ...mockSecurity, - authz: { - mode: { useRbacForRequest: () => true }, - checkPrivilegesWithRequest: () => ({ - globally: () => ({ - hasAllRequested: true, - }), - }), - actions: { ui: { get: () => {} } }, - }, - }; - expect(await checkAccess({ ...mockDependencies, security })).toEqual({ - hasAppSearchAccess: true, - hasWorkplaceSearchAccess: true, + describe('when the spaces plugin is unavailable', () => { + describe('when security is disabled', () => { + it('should allow all access', async () => { + const spaces = undefined; + const security = undefined; + expect(await checkAccess({ ...mockDependencies, spaces, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); }); }); - it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { - const security = { - authz: { - ...mockSecurity.authz, - checkPrivilegesWithRequest: () => ({ - globally: () => Promise.reject({ statusCode: 403 }), - }), - }, - }; - expect(await checkAccess({ ...mockDependencies, security })).toEqual({ - hasAppSearchAccess: false, - hasWorkplaceSearchAccess: false, + describe('when getActiveSpace returns 403 forbidden', () => { + it('should deny all access', async () => { + mockSpaces.spacesService.getActiveSpace.mockReturnValueOnce( + Promise.reject({ output: { statusCode: 403 } }) + ); + expect(await checkAccess({ ...mockDependencies })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); }); }); - it('throws other authz errors', async () => { - const security = { - authz: { - ...mockSecurity.authz, - checkPrivilegesWithRequest: undefined, - }, - }; - await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + describe('when getActiveSpace throws', () => { + it('should re-throw', async () => { + mockSpaces.spacesService.getActiveSpace.mockReturnValueOnce(Promise.reject('Error')); + let expectedError = ''; + try { + await checkAccess({ ...mockDependencies }); + } catch (e) { + expectedError = e; + } + expect(expectedError).toEqual('Error'); + }); }); }); - describe('when the user is a non-superuser', () => { - describe('when enterpriseSearch.host is not set in kibana.yml', () => { - it('should deny all access', async () => { - const config = { host: undefined }; - expect(await checkAccess({ ...mockDependencies, config })).toEqual({ - hasAppSearchAccess: false, - hasWorkplaceSearchAccess: false, + describe('when the space is enabled', () => { + beforeEach(() => { + mockSpaces.spacesService.getActiveSpace.mockResolvedValueOnce(enabledSpace); + }); + + describe('when security is disabled', () => { + it('should allow all access', async () => { + const security = undefined; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, }); }); }); - describe('when enterpriseSearch.host is set in kibana.yml', () => { - it('should make a http call and return the access response', async () => { - (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ - access: { - hasAppSearchAccess: false, - hasWorkplaceSearchAccess: true, + describe('when the user is a superuser', () => { + it('should allow all access when enabled at the space ', async () => { + const security = { + ...mockSecurity, + authz: { + mode: { useRbacForRequest: () => true }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: true, + }), + }), + actions: { ui: { get: () => {} } }, }, - })); - expect(await checkAccess(mockDependencies)).toEqual({ - hasAppSearchAccess: false, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, hasWorkplaceSearchAccess: true, }); }); - it('falls back to no access if no http response', async () => { - (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); - expect(await checkAccess(mockDependencies)).toEqual({ + it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: () => ({ + globally: () => Promise.reject({ statusCode: 403 }), + }), + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ hasAppSearchAccess: false, hasWorkplaceSearchAccess: false, }); }); + + it('throws other authz errors', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: undefined, + }, + }; + await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + }); + }); + + describe('when the user is a non-superuser', () => { + describe('when enterpriseSearch.host is not set in kibana.yml', () => { + it('should deny all access', async () => { + const config = { host: undefined }; + expect(await checkAccess({ ...mockDependencies, config })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when enterpriseSearch.host is set in kibana.yml', () => { + it('should make a http call and return the access response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ + access: { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }, + })); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to no access if no http response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts index 8b32260bb7322..b5a05a57f5e93 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -5,6 +5,7 @@ */ import { KibanaRequest, Logger } from 'src/core/server'; +import { SpacesPluginStart } from '../../../spaces/server'; import { SecurityPluginSetup } from '../../../security/server'; import { ConfigType } from '../'; @@ -13,6 +14,7 @@ import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; interface CheckAccess { request: KibanaRequest; security?: SecurityPluginSetup; + spaces?: SpacesPluginStart; config: ConfigType; log: Logger; } @@ -38,20 +40,53 @@ const DENY_ALL_PLUGINS = { export const checkAccess = async ({ config, security, + spaces, request, log, }: CheckAccess): Promise => { + const isRbacEnabled = security?.authz.mode.useRbacForRequest(request) ?? false; + + // We can only retrieve the active space when either: + // 1) security is enabled, and the request has already been authenticated + // 2) security is disabled + const attemptSpaceRetrieval = !isRbacEnabled || request.auth.isAuthenticated; + + // If we can't retrieve the current space, then assume the feature is available + let allowedAtSpace = false; + + if (!spaces) { + allowedAtSpace = true; + } + + if (spaces && attemptSpaceRetrieval) { + try { + const space = await spaces.spacesService.getActiveSpace(request); + allowedAtSpace = !space.disabledFeatures?.includes('enterpriseSearch'); + } catch (err) { + if (err?.output?.statusCode === 403) { + allowedAtSpace = false; + } else { + throw err; + } + } + } + + // Hide the plugin if turned off in the current space. + if (!allowedAtSpace) { + return DENY_ALL_PLUGINS; + } + // If security has been disabled, always show the plugin - if (!security?.authz.mode.useRbacForRequest(request)) { + if (!isRbacEnabled) { return ALLOW_ALL_PLUGINS; } // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin const isSuperUser = async (): Promise => { try { - const { hasAllRequested } = await security.authz + const { hasAllRequested } = await security!.authz .checkPrivilegesWithRequest(request) - .globally({ kibana: security.authz.actions.ui.get('enterpriseSearch', 'all') }); + .globally({ kibana: security!.authz.actions.ui.get('enterpriseSearch', 'all') }); return hasAllRequested; } catch (err) { if (err.statusCode === 401 || err.statusCode === 403) { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index d8f23674844b8..2d3b27783e3a1 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -16,6 +16,7 @@ import { KibanaRequest, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { SpacesPluginStart } from '../../spaces/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; @@ -51,6 +52,10 @@ interface PluginsSetup { features: FeaturesPluginSetup; } +interface PluginsStart { + spaces?: SpacesPluginStart; +} + export interface RouteDependencies { router: IRouter; config: ConfigType; @@ -69,7 +74,7 @@ export class EnterpriseSearchPlugin implements Plugin { } public async setup( - { capabilities, http, savedObjects, getStartServices }: CoreSetup, + { capabilities, http, savedObjects, getStartServices }: CoreSetup, { usageCollection, security, features }: PluginsSetup ) { const config = await this.config.pipe(first()).toPromise(); @@ -97,7 +102,9 @@ export class EnterpriseSearchPlugin implements Plugin { * Register user access to the Enterprise Search plugins */ capabilities.registerSwitcher(async (request: KibanaRequest) => { - const dependencies = { config, security, request, log }; + const [, { spaces }] = await getStartServices(); + + const dependencies = { config, security, spaces, request, log }; const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); const showEnterpriseSearchOverview = hasAppSearchAccess || hasWorkplaceSearchAccess; diff --git a/x-pack/plugins/event_log/server/event_log_client.ts b/x-pack/plugins/event_log/server/event_log_client.ts index b7de4acb9428c..9b7d4e00b2761 100644 --- a/x-pack/plugins/event_log/server/event_log_client.ts +++ b/x-pack/plugins/event_log/server/event_log_client.ts @@ -7,7 +7,7 @@ import { Observable } from 'rxjs'; import { schema, TypeOf } from '@kbn/config-schema'; import { LegacyClusterClient, KibanaRequest } from 'src/core/server'; -import { SpacesServiceSetup } from '../../spaces/server'; +import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; import { IEventLogClient } from './types'; @@ -60,7 +60,7 @@ export type FindOptionsType = Pick< interface EventLogServiceCtorParams { esContext: EsContext; savedObjectGetter: SavedObjectGetter; - spacesService?: SpacesServiceSetup; + spacesService?: SpacesServiceStart; request: KibanaRequest; } @@ -68,7 +68,7 @@ interface EventLogServiceCtorParams { export class EventLogClient implements IEventLogClient { private esContext: EsContext; private savedObjectGetter: SavedObjectGetter; - private spacesService?: SpacesServiceSetup; + private spacesService?: SpacesServiceStart; private request: KibanaRequest; constructor({ esContext, savedObjectGetter, spacesService, request }: EventLogServiceCtorParams) { diff --git a/x-pack/plugins/event_log/server/event_log_start_service.ts b/x-pack/plugins/event_log/server/event_log_start_service.ts index 5cadab4df3ed7..51dd7d6e95d15 100644 --- a/x-pack/plugins/event_log/server/event_log_start_service.ts +++ b/x-pack/plugins/event_log/server/event_log_start_service.ts @@ -6,7 +6,7 @@ import { Observable } from 'rxjs'; import { LegacyClusterClient, KibanaRequest } from 'src/core/server'; -import { SpacesServiceSetup } from '../../spaces/server'; +import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; import { IEventLogClientService } from './types'; @@ -18,14 +18,14 @@ export type AdminClusterClient$ = Observable; interface EventLogServiceCtorParams { esContext: EsContext; savedObjectProviderRegistry: SavedObjectProviderRegistry; - spacesService?: SpacesServiceSetup; + spacesService?: SpacesServiceStart; } // note that clusterClient may be null, indicating we can't write to ES export class EventLogClientService implements IEventLogClientService { private esContext: EsContext; private savedObjectProviderRegistry: SavedObjectProviderRegistry; - private spacesService?: SpacesServiceSetup; + private spacesService?: SpacesServiceStart; constructor({ esContext, diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index 4439a4fb9fdbb..f69850f166aee 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -17,7 +17,7 @@ import { IContextProvider, RequestHandler, } from 'src/core/server'; -import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server'; +import { SpacesPluginStart } from '../../spaces/server'; import { IEventLogConfig, @@ -41,8 +41,8 @@ const ACTIONS = { stopping: 'stopping', }; -interface PluginSetupDeps { - spaces?: SpacesPluginSetup; +interface PluginStartDeps { + spaces?: SpacesPluginStart; } export class Plugin implements CorePlugin { @@ -53,7 +53,6 @@ export class Plugin implements CorePlugin; private eventLogClientService?: EventLogClientService; - private spacesService?: SpacesServiceSetup; private savedObjectProviderRegistry: SavedObjectProviderRegistry; constructor(private readonly context: PluginInitializerContext) { @@ -63,14 +62,13 @@ export class Plugin implements CorePlugin { + async setup(core: CoreSetup): Promise { const globalConfig = await this.globalConfig$.pipe(first()).toPromise(); const kibanaIndex = globalConfig.kibana.index; this.systemLogger.debug('setting up plugin'); const config = await this.config$.pipe(first()).toPromise(); - this.spacesService = spaces?.spacesService; this.esContext = createEsContext({ logger: this.systemLogger, @@ -105,7 +103,7 @@ export class Plugin implements CorePlugin { + async start(core: CoreStart, { spaces }: PluginStartDeps): Promise { this.systemLogger.debug('starting plugin'); if (!this.esContext) throw new Error('esContext not initialized'); @@ -131,7 +129,7 @@ export class Plugin implements CorePlugin Promise) | undefined, request: RequestFacade ) { async function isMlEnabledInSpace(): Promise { - if (spacesPlugin === undefined) { + if (getSpacesPlugin === undefined) { // if spaces is disabled force isMlEnabledInSpace to be true return true; } - const space = await spacesPlugin.spacesService.getActiveSpace(request); + const space = await (await getSpacesPlugin()).spacesService.getActiveSpace( + request instanceof KibanaRequest ? request : KibanaRequest.from(request) + ); return space.disabledFeatures.includes('ml') === false; } async function getAllSpaces(): Promise { - if (spacesPlugin === undefined) { + if (getSpacesPlugin === undefined) { return null; } - const client = await spacesPlugin.spacesService.scopedClient(request); + const client = (await getSpacesPlugin()).spacesService.createSpacesClient( + request instanceof KibanaRequest ? request : KibanaRequest.from(request) + ); const spaces = await client.getAll(); return spaces.map((s) => s.id); } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 669fc9a1d92e4..5e103dbc1806a 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -18,8 +18,8 @@ import { } from 'kibana/server'; import type { SecurityPluginSetup } from '../../security/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import { PluginsSetup, PluginsStart, RouteInitialization } from './types'; import { SpacesPluginSetup } from '../../spaces/server'; -import { PluginsSetup, RouteInitialization } from './types'; import { PLUGIN_ID } from '../common/constants/app'; import { MlCapabilities } from '../common/types/capabilities'; @@ -61,7 +61,8 @@ import { RouteGuard } from './lib/route_guard'; export type MlPluginSetup = SharedServices; export type MlPluginStart = void; -export class MlServerPlugin implements Plugin { +export class MlServerPlugin + implements Plugin { private log: Logger; private version: string; private mlLicense: MlLicense; @@ -80,7 +81,7 @@ export class MlServerPlugin implements Plugin (this.setMlReady = resolve)); } - public setup(coreSetup: CoreSetup, plugins: PluginsSetup): MlPluginSetup { + public setup(coreSetup: CoreSetup, plugins: PluginsSetup): MlPluginSetup { this.spacesPlugin = plugins.spaces; this.security = plugins.security; const { admin, user, apmUser } = getPluginPrivileges(); @@ -157,6 +158,10 @@ export class MlServerPlugin implements Plugin coreSetup.getStartServices().then(([, { spaces }]) => spaces!) + : undefined; + annotationRoutes(routeInit, plugins.security); calendars(routeInit); dataFeedRoutes(routeInit); @@ -175,7 +180,7 @@ export class MlServerPlugin implements Plugin { try { - const { isMlEnabledInSpace } = spacesUtilsProvider(spaces, (request as unknown) as Request); + const { isMlEnabledInSpace } = spacesUtilsProvider(getSpaces, request); const mlCapabilities = await resolveMlCapabilities(request); if (mlCapabilities === null) { diff --git a/x-pack/plugins/ml/server/shared_services/providers/system.ts b/x-pack/plugins/ml/server/shared_services/providers/system.ts index c7c50eb74595e..b1494546c89f4 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/system.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/system.ts @@ -10,7 +10,7 @@ import { RequestParams } from '@elastic/elasticsearch'; import { MlLicense } from '../../../common/license'; import { CloudSetup } from '../../../../cloud/server'; import { spacesUtilsProvider } from '../../lib/spaces_utils'; -import { SpacesPluginSetup } from '../../../../spaces/server'; +import { SpacesPluginStart } from '../../../../spaces/server'; import { capabilitiesProvider } from '../../lib/capabilities'; import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { MlCapabilitiesResponse, ResolveMlCapabilities } from '../../../common/types/capabilities'; @@ -33,7 +33,7 @@ export interface MlSystemProvider { export function getMlSystemProvider( getGuards: GetGuards, mlLicense: MlLicense, - spaces: SpacesPluginSetup | undefined, + getSpaces: (() => Promise) | undefined, cloud: CloudSetup | undefined, resolveMlCapabilities: ResolveMlCapabilities ): MlSystemProvider { @@ -44,7 +44,7 @@ export function getMlSystemProvider( return await getGuards(request, savedObjectsClient) .isMinimumLicense() .ok(async ({ mlClient }) => { - const { isMlEnabledInSpace } = spacesUtilsProvider(spaces, request); + const { isMlEnabledInSpace } = spacesUtilsProvider(getSpaces, request); const mlCapabilities = await resolveMlCapabilities(request); if (mlCapabilities === null) { diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index dc7bc06fde7d5..0699c1af3086a 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -5,11 +5,8 @@ */ import { IClusterClient, IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; -import { SpacesPluginSetup } from '../../../spaces/server'; -// including KibanaRequest from 'kibana/server' causes an error -// when being used with instanceof -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { KibanaRequest } from '../../.././../../src/core/server/http'; +import { SpacesPluginStart } from '../../../spaces/server'; +import { KibanaRequest } from '../../.././../../src/core/server'; import { MlLicense } from '../../common/license'; import type { CloudSetup } from '../../../cloud/server'; @@ -61,7 +58,7 @@ type OkCallback = (okParams: OkParams) => any; export function createSharedServices( mlLicense: MlLicense, - spacesPlugin: SpacesPluginSetup | undefined, + getSpaces: (() => Promise) | undefined, cloud: CloudSetup, authorization: SecurityPluginSetup['authz'] | undefined, resolveMlCapabilities: ResolveMlCapabilities, @@ -84,7 +81,7 @@ export function createSharedServices( savedObjectsClient, internalSavedObjectsClient, authorization, - spacesPlugin !== undefined, + getSpaces !== undefined, isMlReady ); @@ -119,7 +116,7 @@ export function createSharedServices( ...getAnomalyDetectorsProvider(getGuards), ...getModulesProvider(getGuards), ...getResultsServiceProvider(getGuards), - ...getMlSystemProvider(getGuards, mlLicense, spacesPlugin, cloud, resolveMlCapabilities), + ...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities), }; } diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index 4a43a3e3f173c..df40f5a26b0f3 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -11,7 +11,7 @@ import type { CloudSetup } from '../../cloud/server'; import type { SecurityPluginSetup } from '../../security/server'; import type { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import type { LicensingPluginSetup } from '../../licensing/server'; -import type { SpacesPluginSetup } from '../../spaces/server'; +import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import type { MlLicense } from '../common/license'; import type { ResolveMlCapabilities } from '../common/types/capabilities'; import type { RouteGuard } from './lib/route_guard'; @@ -27,7 +27,7 @@ export interface LicenseCheckResult { export interface SystemRouteDeps { cloud: CloudSetup; - spaces?: SpacesPluginSetup; + getSpaces?: () => Promise; resolveMlCapabilities: ResolveMlCapabilities; } @@ -41,6 +41,10 @@ export interface PluginsSetup { usageCollection: UsageCollectionSetup; } +export interface PluginsStart { + spaces?: SpacesPluginStart; +} + export interface RouteInitialization { router: IRouter; mlLicense: MlLicense; diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 40629dbe4f3b3..f6e7b8bf46a39 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "security"], "requiredPlugins": ["data", "features", "licensing", "taskManager", "securityOss"], - "optionalPlugins": ["home", "management", "usageCollection"], + "optionalPlugins": ["home", "management", "usageCollection", "spaces"], "server": true, "ui": true, "requiredBundles": [ diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index cf9a30b0b3857..65f9e76c4ee09 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -114,7 +114,6 @@ describe('Security Plugin', () => { "isEnabled": [Function], "isLicenseAvailable": [Function], }, - "registerSpacesService": [Function], } `); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 52283290ba7b7..17f2480026cc7 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -16,7 +16,7 @@ import { Logger, PluginInitializerContext, } from '../../../../src/core/server'; -import { SpacesPluginSetup } from '../../spaces/server'; +import { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; import { PluginSetupContract as FeaturesPluginSetup, @@ -37,6 +37,7 @@ import { securityFeatures } from './features'; import { ElasticsearchService } from './elasticsearch'; import { SessionManagementService } from './session_management'; import { registerSecurityUsageCollector } from './usage_collector'; +import { setupSpacesClient } from './spaces'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], @@ -68,16 +69,6 @@ export interface SecurityPluginSetup { >; license: SecurityLicense; audit: AuditServiceSetup; - - /** - * If Spaces plugin is available it's supposed to register its SpacesService with Security plugin - * so that Security can get space ID from the URL or namespace. We can't declare optional dependency - * to Spaces since it'd result into circular dependency between these two plugins and circular - * dependencies aren't supported by the Core. In the future we have to get rid of this implicit - * dependency. - * @param service Spaces service exposed by the Spaces plugin. - */ - registerSpacesService: (service: SpacesService) => void; } export interface PluginSetupDependencies { @@ -86,12 +77,14 @@ export interface PluginSetupDependencies { taskManager: TaskManagerSetupContract; usageCollection?: UsageCollectionSetup; securityOss?: SecurityOssPluginSetup; + spaces?: SpacesPluginSetup; } export interface PluginStartDependencies { features: FeaturesPluginStart; licensing: LicensingPluginStart; taskManager: TaskManagerStartContract; + spaces?: SpacesPluginStart; } /** @@ -99,7 +92,6 @@ export interface PluginStartDependencies { */ export class Plugin { private readonly logger: Logger; - private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; private authc?: Authentication; @@ -121,22 +113,20 @@ export class Plugin { this.initializerContext.logger.get('session') ); - private readonly getSpacesService = () => { - // Changing property value from Symbol to undefined denotes the fact that property was accessed. - if (!this.wasSpacesServiceAccessed()) { - this.spacesService = undefined; - } - - return this.spacesService as SpacesService | undefined; - }; - constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } public async setup( core: CoreSetup, - { features, licensing, taskManager, usageCollection, securityOss }: PluginSetupDependencies + { + features, + licensing, + taskManager, + usageCollection, + securityOss, + spaces, + }: PluginSetupDependencies ) { const [config, legacyConfig] = await combineLatest([ this.initializerContext.config.create>().pipe( @@ -182,7 +172,7 @@ export class Plugin { config: config.audit, logging: core.logging, http: core.http, - getSpaceId: (request) => this.getSpacesService()?.getSpaceId(request), + getSpaceId: (request) => spaces?.spacesService.getSpaceId(request), getCurrentUser: (request) => this.authc?.getCurrentUser(request), }); const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); @@ -216,17 +206,23 @@ export class Plugin { kibanaIndexName: legacyConfig.kibana.index, packageVersion: this.initializerContext.env.packageInfo.version, buildNumber: this.initializerContext.env.packageInfo.buildNum, - getSpacesService: this.getSpacesService, + getSpacesService: () => spaces?.spacesService, features, getCurrentUser: this.authc.getCurrentUser, }); + setupSpacesClient({ + spaces, + audit, + authz, + }); + setupSavedObjects({ legacyAuditLogger, audit, authz, savedObjects: core.savedObjects, - getSpacesService: this.getSpacesService, + getSpacesService: () => spaces?.spacesService, }); defineRoutes({ @@ -271,14 +267,6 @@ export class Plugin { }, license, - - registerSpacesService: (service) => { - if (this.wasSpacesServiceAccessed()) { - throw new Error('Spaces service has been accessed before registration.'); - } - - this.spacesService = service; - }, }); } @@ -312,8 +300,4 @@ export class Plugin { this.elasticsearchService.stop(); this.sessionManagementService.stop(); } - - private wasSpacesServiceAccessed() { - return typeof this.spacesService !== 'symbol'; - } } diff --git a/x-pack/plugins/security/server/routes/authorization/index.ts b/x-pack/plugins/security/server/routes/authorization/index.ts index 699ffb5e81ffc..75bfcf65b3965 100644 --- a/x-pack/plugins/security/server/routes/authorization/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/index.ts @@ -7,10 +7,12 @@ import { definePrivilegesRoutes } from './privileges'; import { defineRolesRoutes } from './roles'; import { resetSessionPageRoutes } from './reset_session_page'; +import { defineShareSavedObjectPermissionRoutes } from './spaces'; import { RouteDefinitionParams } from '..'; export function defineAuthorizationRoutes(params: RouteDefinitionParams) { defineRolesRoutes(params); definePrivilegesRoutes(params); resetSessionPageRoutes(params); + defineShareSavedObjectPermissionRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/authorization/spaces/index.ts b/x-pack/plugins/security/server/routes/authorization/spaces/index.ts new file mode 100644 index 0000000000000..eb72a13fd7a15 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/spaces/index.ts @@ -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 { defineShareSavedObjectPermissionRoutes } from './share_saved_object_permissions'; diff --git a/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.test.ts b/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.test.ts new file mode 100644 index 0000000000000..ccdee8b100039 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.test.ts @@ -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 { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../../src/core/server'; +import { defineShareSavedObjectPermissionRoutes } from './share_saved_object_permissions'; + +import { httpServerMock } from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; +import { RouteDefinitionParams } from '../..'; +import { DeeplyMockedKeys } from '@kbn/utility-types/target/jest'; +import { CheckPrivileges } from '../../../authorization/types'; + +describe('Share Saved Object Permissions', () => { + let router: jest.Mocked; + let routeParamsMock: DeeplyMockedKeys; + + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: 'valid' }) }, + }, + } as unknown) as RequestHandlerContext; + + beforeEach(() => { + routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router as jest.Mocked; + + defineShareSavedObjectPermissionRoutes(routeParamsMock); + }); + + describe('GET /internal/security/_share_saved_object_permissions', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [shareRouteConfig, shareRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/internal/security/_share_saved_object_permissions' + )!; + + routeConfig = shareRouteConfig; + routeHandler = shareRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toHaveProperty('query'); + }); + + it('returns `true` when the user is authorized globally', async () => { + const checkPrivilegesWithRequest = jest.fn().mockResolvedValue({ hasAllRequested: true }); + + routeParamsMock.authz.checkPrivilegesWithRequest.mockReturnValue(({ + globally: checkPrivilegesWithRequest, + } as unknown) as CheckPrivileges); + + const request = httpServerMock.createKibanaRequest({ + query: { + type: 'foo-type', + }, + }); + + await expect( + routeHandler(mockContext, request, kibanaResponseFactory) + ).resolves.toMatchObject({ + status: 200, + payload: { + shareToAllSpaces: true, + }, + }); + + expect(routeParamsMock.authz.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(checkPrivilegesWithRequest).toHaveBeenCalledWith({ + kibana: routeParamsMock.authz.actions.savedObject.get('foo-type', 'share-to-space'), + }); + }); + + it('returns `false` when the user is not authorized globally', async () => { + const checkPrivilegesWithRequest = jest.fn().mockResolvedValue({ hasAllRequested: false }); + + routeParamsMock.authz.checkPrivilegesWithRequest.mockReturnValue(({ + globally: checkPrivilegesWithRequest, + } as unknown) as CheckPrivileges); + + const request = httpServerMock.createKibanaRequest({ + query: { + type: 'foo-type', + }, + }); + + await expect( + routeHandler(mockContext, request, kibanaResponseFactory) + ).resolves.toMatchObject({ + status: 200, + payload: { + shareToAllSpaces: false, + }, + }); + + expect(routeParamsMock.authz.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(checkPrivilegesWithRequest).toHaveBeenCalledWith({ + kibana: routeParamsMock.authz.actions.savedObject.get('foo-type', 'share-to-space'), + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts b/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts new file mode 100644 index 0000000000000..edfdef34b7fbf --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts @@ -0,0 +1,35 @@ +/* + * 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 { RouteDefinitionParams } from '../../index'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { wrapIntoCustomErrorResponse } from '../../../errors'; + +export function defineShareSavedObjectPermissionRoutes({ router, authz }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/_share_saved_object_permissions', + validate: { query: schema.object({ type: schema.string() }) }, + }, + createLicensedRouteHandler(async (context, request, response) => { + let shareToAllSpaces = true; + const { type } = request.query; + + try { + const checkPrivileges = authz.checkPrivilegesWithRequest(request); + shareToAllSpaces = ( + await checkPrivileges.globally({ + kibana: authz.actions.savedObject.get(type, 'share_to_space'), + }) + ).hasAllRequested; + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + return response.ok({ body: { shareToAllSpaces } }); + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index b4698708f86fe..fab4a71df0cb0 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -15,23 +15,26 @@ import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; import { sessionMock } from '../session_management/session.mock'; +import { RouteDefinitionParams } from '.'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; export const routeDefinitionParamsMock = { - create: (config: Record = {}) => ({ - router: httpServiceMock.createRouter(), - basePath: httpServiceMock.createBasePath(), - csp: httpServiceMock.createSetupContract().csp, - logger: loggingSystemMock.create().get(), - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), - config: createConfig(ConfigSchema.validate(config), loggingSystemMock.create().get(), { - isTLSEnabled: false, - }), - authc: authenticationMock.create(), - authz: authorizationMock.create(), - license: licenseMock.create(), - httpResources: httpResourcesMock.createRegistrar(), - getFeatures: jest.fn(), - getFeatureUsageService: jest.fn(), - session: sessionMock.create(), - }), + create: (config: Record = {}) => + (({ + router: httpServiceMock.createRouter(), + basePath: httpServiceMock.createBasePath(), + csp: httpServiceMock.createSetupContract().csp, + logger: loggingSystemMock.create().get(), + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + config: createConfig(ConfigSchema.validate(config), loggingSystemMock.create().get(), { + isTLSEnabled: false, + }), + authc: authenticationMock.create(), + authz: authorizationMock.create(), + license: licenseMock.create(), + httpResources: httpResourcesMock.createRegistrar(), + getFeatures: jest.fn(), + getFeatureUsageService: jest.fn(), + session: sessionMock.create(), + } as unknown) as DeeplyMockedKeys), }; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/index.ts b/x-pack/plugins/security/server/spaces/index.ts similarity index 80% rename from x-pack/plugins/spaces/server/lib/spaces_client/index.ts rename to x-pack/plugins/security/server/spaces/index.ts index 54c778ae3839e..264cc55a777ca 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/index.ts +++ b/x-pack/plugins/security/server/spaces/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SpacesClient } from './spaces_client'; +export { setupSpacesClient } from './setup_spaces_client'; diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.test.ts b/x-pack/plugins/security/server/spaces/legacy_audit_logger.test.ts similarity index 87% rename from x-pack/plugins/spaces/server/lib/audit_logger.test.ts rename to x-pack/plugins/security/server/spaces/legacy_audit_logger.test.ts index 94e9a6a35be64..bbd91f0fa8d41 100644 --- a/x-pack/plugins/spaces/server/lib/audit_logger.test.ts +++ b/x-pack/plugins/security/server/spaces/legacy_audit_logger.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { SpacesAuditLogger } from './audit_logger'; +import { LegacySpacesAuditLogger } from './legacy_audit_logger'; const createMockAuditLogger = () => { return { @@ -14,7 +14,7 @@ const createMockAuditLogger = () => { describe(`#savedObjectsAuthorizationFailure`, () => { test('logs auth failure with spaceIds via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const securityAuditLogger = new LegacySpacesAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; const spaceIds = ['foo-space-1', 'foo-space-2']; @@ -34,7 +34,7 @@ describe(`#savedObjectsAuthorizationFailure`, () => { test('logs auth failure without spaceIds via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const securityAuditLogger = new LegacySpacesAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; @@ -54,7 +54,7 @@ describe(`#savedObjectsAuthorizationFailure`, () => { describe(`#savedObjectsAuthorizationSuccess`, () => { test('logs auth success with spaceIds via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const securityAuditLogger = new LegacySpacesAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; const spaceIds = ['foo-space-1', 'foo-space-2']; @@ -74,7 +74,7 @@ describe(`#savedObjectsAuthorizationSuccess`, () => { test('logs auth success without spaceIds via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const securityAuditLogger = new LegacySpacesAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.ts b/x-pack/plugins/security/server/spaces/legacy_audit_logger.ts similarity index 78% rename from x-pack/plugins/spaces/server/lib/audit_logger.ts rename to x-pack/plugins/security/server/spaces/legacy_audit_logger.ts index 8110e3fbc6624..88cb30c751045 100644 --- a/x-pack/plugins/spaces/server/lib/audit_logger.ts +++ b/x-pack/plugins/security/server/spaces/legacy_audit_logger.ts @@ -4,14 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAuditLogger } from '../../../security/server'; +import { LegacyAuditLogger } from '../audit'; -export class SpacesAuditLogger { +/** + * @deprecated will be removed in 8.0 + */ +export class LegacySpacesAuditLogger { private readonly auditLogger: LegacyAuditLogger; + /** + * @deprecated will be removed in 8.0 + */ constructor(auditLogger: LegacyAuditLogger = { log() {} }) { this.auditLogger = auditLogger; } + + /** + * @deprecated will be removed in 8.0 + */ public spacesAuthorizationFailure(username: string, action: string, spaceIds?: string[]) { this.auditLogger.log( 'spaces_authorization_failure', @@ -24,6 +34,9 @@ export class SpacesAuditLogger { ); } + /** + * @deprecated will be removed in 8.0 + */ public spacesAuthorizationSuccess(username: string, action: string, spaceIds?: string[]) { this.auditLogger.log( 'spaces_authorization_success', diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts new file mode 100644 index 0000000000000..90ee95f518089 --- /dev/null +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts @@ -0,0 +1,623 @@ +/* + * 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 { httpServerMock } from '../../../../../src/core/server/mocks'; + +import { SecureSpacesClientWrapper } from './secure_spaces_client_wrapper'; + +import { spacesClientMock } from '../../../spaces/server/mocks'; +import { deepFreeze } from '@kbn/std'; +import { Space } from '../../../spaces/server'; +import { authorizationMock } from '../authorization/index.mock'; +import { AuthorizationServiceSetup } from '../authorization'; +import { GetAllSpacesPurpose } from '../../../spaces/common/model/types'; +import { CheckPrivilegesResponse } from '../authorization/types'; +import { LegacySpacesAuditLogger } from './legacy_audit_logger'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; + +interface Opts { + securityEnabled?: boolean; +} + +const spaces = (deepFreeze([ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: 'marketing', + name: 'Marketing Space', + disabledFeatures: [], + }, + { + id: 'sales', + name: 'Sales Space', + disabledFeatures: [], + }, +]) as unknown) as Space[]; + +const setup = ({ securityEnabled = false }: Opts = {}) => { + const baseClient = spacesClientMock.create(); + baseClient.getAll.mockResolvedValue([...spaces]); + + baseClient.get.mockImplementation(async (spaceId: string) => { + const space = spaces.find((s) => s.id === spaceId); + if (!space) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError('space', spaceId); + } + return space; + }); + + const authorization = authorizationMock.create({ + version: 'unit-test', + applicationName: 'kibana', + }); + authorization.mode.useRbacForRequest.mockReturnValue(securityEnabled); + + const legacyAuditLogger = ({ + spacesAuthorizationFailure: jest.fn(), + spacesAuthorizationSuccess: jest.fn(), + } as unknown) as jest.Mocked; + + const request = httpServerMock.createKibanaRequest(); + const wrapper = new SecureSpacesClientWrapper( + baseClient, + request, + authorization, + legacyAuditLogger + ); + return { + authorization, + wrapper, + request, + baseClient, + legacyAuditLogger, + }; +}; + +const expectNoAuthorizationCheck = (authorization: jest.Mocked) => { + expect(authorization.checkPrivilegesDynamicallyWithRequest).not.toHaveBeenCalled(); + expect(authorization.checkPrivilegesWithRequest).not.toHaveBeenCalled(); + expect(authorization.checkSavedObjectsPrivilegesWithRequest).not.toHaveBeenCalled(); +}; + +const expectNoAuditLogging = (auditLogger: jest.Mocked) => { + expect(auditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); +}; + +const expectForbiddenAuditLogging = ( + auditLogger: jest.Mocked, + username: string, + operation: string, + spaceId?: string +) => { + expect(auditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(1); + if (spaceId) { + expect(auditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, operation, [ + spaceId, + ]); + } else { + expect(auditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, operation); + } + + expect(auditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); +}; + +const expectSuccessAuditLogging = ( + auditLogger: jest.Mocked, + username: string, + operation: string, + spaceIds?: string[] +) => { + expect(auditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(1); + if (spaceIds) { + expect(auditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith( + username, + operation, + spaceIds + ); + } else { + expect(auditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, operation); + } + + expect(auditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); +}; + +describe('SecureSpacesClientWrapper', () => { + describe('#getAll', () => { + const savedObjects = [ + { + id: 'default', + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }, + { + id: 'marketing', + attributes: { + name: 'bar-name', + description: 'bar-description', + bar: 'bar-bar', + }, + }, + { + id: 'sales', + attributes: { + name: 'bar-name', + description: 'bar-description', + bar: 'bar-bar', + }, + }, + ]; + + it('delegates to base client when security is not enabled', async () => { + const { wrapper, baseClient, authorization, legacyAuditLogger } = setup({ + securityEnabled: false, + }); + + const response = await wrapper.getAll(); + expect(baseClient.getAll).toHaveBeenCalledTimes(1); + expect(baseClient.getAll).toHaveBeenCalledWith({ purpose: 'any' }); + expect(response).toEqual(spaces); + expectNoAuthorizationCheck(authorization); + expectNoAuditLogging(legacyAuditLogger); + }); + + [ + { + purpose: undefined, + expectedPrivilege: (mockAuthorization: AuthorizationServiceSetup) => [ + mockAuthorization.actions.login, + ], + }, + { + purpose: 'any' as GetAllSpacesPurpose, + expectedPrivilege: (mockAuthorization: AuthorizationServiceSetup) => [ + mockAuthorization.actions.login, + ], + }, + { + purpose: 'copySavedObjectsIntoSpace' as GetAllSpacesPurpose, + expectedPrivilege: (mockAuthorization: AuthorizationServiceSetup) => [ + mockAuthorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), + ], + }, + { + purpose: 'findSavedObjects' as GetAllSpacesPurpose, + expectedPrivilege: (mockAuthorization: AuthorizationServiceSetup) => [ + mockAuthorization.actions.login, + mockAuthorization.actions.savedObject.get('config', 'find'), + ], + }, + { + purpose: 'shareSavedObjectsIntoSpace' as GetAllSpacesPurpose, + expectedPrivilege: (mockAuthorization: AuthorizationServiceSetup) => [ + mockAuthorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + ], + }, + ].forEach((scenario) => { + describe(`with purpose='${scenario.purpose}'`, () => { + test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { + const username = 'some-user'; + const { authorization, wrapper, baseClient, request, legacyAuditLogger } = setup({ + securityEnabled: true, + }); + + const privileges = scenario.expectedPrivilege(authorization); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + privileges: { + kibana: [ + ...privileges + .map((privilege) => [ + { resource: savedObjects[0].id, privilege, authorized: false }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ]) + .flat(), + ], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ atSpaces: checkPrivileges }); + + await expect(wrapper.getAll({ purpose: scenario.purpose })).rejects.toThrowError( + 'Forbidden' + ); + + expect(baseClient.getAll).toHaveBeenCalledWith({ purpose: scenario.purpose ?? 'any' }); + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(checkPrivileges).toHaveBeenCalledWith( + savedObjects.map((savedObject) => savedObject.id), + { kibana: privileges } + ); + + expectForbiddenAuditLogging(legacyAuditLogger, username, 'getAll'); + }); + + test(`returns spaces that the user is authorized for`, async () => { + const username = 'some-user'; + const { authorization, wrapper, baseClient, request, legacyAuditLogger } = setup({ + securityEnabled: true, + }); + + const privileges = scenario.expectedPrivilege(authorization); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + privileges: { + kibana: [ + ...privileges + .map((privilege) => [ + { resource: savedObjects[0].id, privilege, authorized: true }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ]) + .flat(), + ], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ atSpaces: checkPrivileges }); + + const actualSpaces = await wrapper.getAll({ purpose: scenario.purpose }); + + expect(actualSpaces).toEqual([spaces[0]]); + expect(baseClient.getAll).toHaveBeenCalledWith({ purpose: scenario.purpose ?? 'any' }); + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(checkPrivileges).toHaveBeenCalledWith( + savedObjects.map((savedObject) => savedObject.id), + { kibana: privileges } + ); + + expectSuccessAuditLogging(legacyAuditLogger, username, 'getAll', [spaces[0].id]); + }); + }); + }); + }); + + describe('#get', () => { + it('delegates to base client when security is not enabled', async () => { + const { wrapper, baseClient, authorization, legacyAuditLogger } = setup({ + securityEnabled: false, + }); + + const response = await wrapper.get('default'); + expect(baseClient.get).toHaveBeenCalledTimes(1); + expect(baseClient.get).toHaveBeenCalledWith('default'); + expect(response).toEqual(spaces[0]); + expectNoAuthorizationCheck(authorization); + expectNoAuditLogging(legacyAuditLogger); + }); + + test(`throws a forbidden error when unauthorized`, async () => { + const username = 'some_user'; + const spaceId = 'default'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: false, + privileges: { + kibana: [ + { resource: spaceId, privilege: authorization.actions.login, authorized: false }, + ], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ atSpace: checkPrivileges }); + + await expect(wrapper.get(spaceId)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to get default space"` + ); + + expect(baseClient.get).not.toHaveBeenCalled(); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith(spaceId, { + kibana: authorization.actions.login, + }); + + expectForbiddenAuditLogging(legacyAuditLogger, username, 'get', spaceId); + }); + + it('returns the space when authorized', async () => { + const username = 'some_user'; + const spaceId = 'default'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: true, + privileges: { + kibana: [{ resource: spaceId, privilege: authorization.actions.login, authorized: true }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ atSpace: checkPrivileges }); + + const response = await wrapper.get(spaceId); + + expect(baseClient.get).toHaveBeenCalledTimes(1); + expect(baseClient.get).toHaveBeenCalledWith(spaceId); + + expect(response).toEqual(spaces[0]); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith(spaceId, { + kibana: authorization.actions.login, + }); + + expectSuccessAuditLogging(legacyAuditLogger, username, 'get', [spaceId]); + }); + }); + + describe('#create', () => { + const space = Object.freeze({ + id: 'new_space', + name: 'new space', + disabledFeatures: [], + }); + + it('delegates to base client when security is not enabled', async () => { + const { wrapper, baseClient, authorization, legacyAuditLogger } = setup({ + securityEnabled: false, + }); + + const response = await wrapper.create(space); + expect(baseClient.create).toHaveBeenCalledTimes(1); + expect(baseClient.create).toHaveBeenCalledWith(space); + expect(response).toEqual(space); + expectNoAuthorizationCheck(authorization); + expectNoAuditLogging(legacyAuditLogger); + }); + + test(`throws a forbidden error when unauthorized`, async () => { + const username = 'some_user'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: false, + privileges: { + kibana: [{ privilege: authorization.actions.space.manage, authorized: false }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ globally: checkPrivileges }); + + await expect(wrapper.create(space)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to create spaces"` + ); + + expect(baseClient.create).not.toHaveBeenCalled(); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: authorization.actions.space.manage, + }); + + expectForbiddenAuditLogging(legacyAuditLogger, username, 'create'); + }); + + it('creates the space when authorized', async () => { + const username = 'some_user'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: true, + privileges: { + kibana: [{ privilege: authorization.actions.space.manage, authorized: true }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ globally: checkPrivileges }); + + const response = await wrapper.create(space); + + expect(baseClient.create).toHaveBeenCalledTimes(1); + expect(baseClient.create).toHaveBeenCalledWith(space); + + expect(response).toEqual(space); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: authorization.actions.space.manage, + }); + + expectSuccessAuditLogging(legacyAuditLogger, username, 'create'); + }); + }); + + describe('#update', () => { + const space = Object.freeze({ + id: 'existing_space', + name: 'existing space', + disabledFeatures: [], + }); + + it('delegates to base client when security is not enabled', async () => { + const { wrapper, baseClient, authorization, legacyAuditLogger } = setup({ + securityEnabled: false, + }); + + const response = await wrapper.update(space.id, space); + expect(baseClient.update).toHaveBeenCalledTimes(1); + expect(baseClient.update).toHaveBeenCalledWith(space.id, space); + expect(response).toEqual(space.id); + expectNoAuthorizationCheck(authorization); + expectNoAuditLogging(legacyAuditLogger); + }); + + test(`throws a forbidden error when unauthorized`, async () => { + const username = 'some_user'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: false, + privileges: { + kibana: [{ privilege: authorization.actions.space.manage, authorized: false }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ globally: checkPrivileges }); + + await expect(wrapper.update(space.id, space)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to update spaces"` + ); + + expect(baseClient.update).not.toHaveBeenCalled(); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: authorization.actions.space.manage, + }); + + expectForbiddenAuditLogging(legacyAuditLogger, username, 'update'); + }); + + it('updates the space when authorized', async () => { + const username = 'some_user'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: true, + privileges: { + kibana: [{ privilege: authorization.actions.space.manage, authorized: true }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ globally: checkPrivileges }); + + const response = await wrapper.update(space.id, space); + + expect(baseClient.update).toHaveBeenCalledTimes(1); + expect(baseClient.update).toHaveBeenCalledWith(space.id, space); + + expect(response).toEqual(space.id); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: authorization.actions.space.manage, + }); + + expectSuccessAuditLogging(legacyAuditLogger, username, 'update'); + }); + }); + + describe('#delete', () => { + const space = Object.freeze({ + id: 'existing_space', + name: 'existing space', + disabledFeatures: [], + }); + + it('delegates to base client when security is not enabled', async () => { + const { wrapper, baseClient, authorization, legacyAuditLogger } = setup({ + securityEnabled: false, + }); + + await wrapper.delete(space.id); + expect(baseClient.delete).toHaveBeenCalledTimes(1); + expect(baseClient.delete).toHaveBeenCalledWith(space.id); + expectNoAuthorizationCheck(authorization); + expectNoAuditLogging(legacyAuditLogger); + }); + + test(`throws a forbidden error when unauthorized`, async () => { + const username = 'some_user'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: false, + privileges: { + kibana: [{ privilege: authorization.actions.space.manage, authorized: false }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ globally: checkPrivileges }); + + await expect(wrapper.delete(space.id)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to delete spaces"` + ); + + expect(baseClient.delete).not.toHaveBeenCalled(); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: authorization.actions.space.manage, + }); + + expectForbiddenAuditLogging(legacyAuditLogger, username, 'delete'); + }); + + it('deletes the space when authorized', async () => { + const username = 'some_user'; + + const { wrapper, baseClient, authorization, legacyAuditLogger, request } = setup({ + securityEnabled: true, + }); + + const checkPrivileges = jest.fn().mockResolvedValue({ + username, + hasAllRequested: true, + privileges: { + kibana: [{ privilege: authorization.actions.space.manage, authorized: true }], + }, + } as CheckPrivilegesResponse); + authorization.checkPrivilegesWithRequest.mockReturnValue({ globally: checkPrivileges }); + + await wrapper.delete(space.id); + + expect(baseClient.delete).toHaveBeenCalledTimes(1); + expect(baseClient.delete).toHaveBeenCalledWith(space.id); + + expect(authorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: authorization.actions.space.manage, + }); + + expectSuccessAuditLogging(legacyAuditLogger, username, 'delete'); + }); + }); +}); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts new file mode 100644 index 0000000000000..bd65673422fc1 --- /dev/null +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts @@ -0,0 +1,204 @@ +/* + * 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 '@hapi/boom'; +import { KibanaRequest } from 'src/core/server'; +import { GetAllSpacesPurpose, GetSpaceResult } from '../../../spaces/common/model/types'; +import { Space, ISpacesClient } from '../../../spaces/server'; +import { LegacySpacesAuditLogger } from './legacy_audit_logger'; +import { AuthorizationServiceSetup } from '../authorization'; +import { SecurityPluginSetup } from '..'; + +const PURPOSE_PRIVILEGE_MAP: Record< + GetAllSpacesPurpose, + (authorization: SecurityPluginSetup['authz']) => string[] +> = { + any: (authorization) => [authorization.actions.login], + copySavedObjectsIntoSpace: (authorization) => [ + authorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), + ], + findSavedObjects: (authorization) => { + return [authorization.actions.login, authorization.actions.savedObject.get('config', 'find')]; + }, + shareSavedObjectsIntoSpace: (authorization) => [ + authorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + ], +}; + +interface GetAllSpacesOptions { + purpose?: GetAllSpacesPurpose; + includeAuthorizedPurposes?: boolean; +} + +export class SecureSpacesClientWrapper implements ISpacesClient { + private readonly useRbac = this.authorization.mode.useRbacForRequest(this.request); + + constructor( + private readonly spacesClient: ISpacesClient, + private readonly request: KibanaRequest, + private readonly authorization: AuthorizationServiceSetup, + private readonly legacyAuditLogger: LegacySpacesAuditLogger + ) {} + + public async getAll({ + purpose = 'any', + includeAuthorizedPurposes, + }: GetAllSpacesOptions = {}): Promise { + const allSpaces = await this.spacesClient.getAll({ purpose, includeAuthorizedPurposes }); + + if (!this.useRbac) { + return allSpaces; + } + + const spaceIds = allSpaces.map((space: Space) => space.id); + + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + + // Collect all privileges which need to be checked + const allPrivileges = Object.entries(PURPOSE_PRIVILEGE_MAP).reduce( + (acc, [getSpacesPurpose, privilegeFactory]) => + !includeAuthorizedPurposes && getSpacesPurpose !== purpose + ? acc + : { ...acc, [getSpacesPurpose]: privilegeFactory(this.authorization) }, + {} as Record + ); + + // Check all privileges against all spaces + const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, { + kibana: Object.values(allPrivileges).flat(), + }); + + // Determine which purposes the user is authorized for within each space. + // Remove any spaces for which user is fully unauthorized. + const checkHasAllRequired = (space: Space, actions: string[]) => + actions.every((action) => + privileges.kibana.some( + ({ resource, privilege, authorized }) => + resource === space.id && privilege === action && authorized + ) + ); + const authorizedSpaces: GetSpaceResult[] = allSpaces + .map((space: Space) => { + if (!includeAuthorizedPurposes) { + // Check if the user is authorized for a single purpose + const requiredActions = PURPOSE_PRIVILEGE_MAP[purpose](this.authorization); + return checkHasAllRequired(space, requiredActions) ? space : null; + } + + // Check if the user is authorized for each purpose + let hasAnyAuthorization = false; + const authorizedPurposes = Object.entries(PURPOSE_PRIVILEGE_MAP).reduce( + (acc, [purposeKey, privilegeFactory]) => { + const requiredActions = privilegeFactory(this.authorization); + const hasAllRequired = checkHasAllRequired(space, requiredActions); + hasAnyAuthorization = hasAnyAuthorization || hasAllRequired; + return { ...acc, [purposeKey]: hasAllRequired }; + }, + {} as Record + ); + + if (!hasAnyAuthorization) { + return null; + } + return { ...space, authorizedPurposes }; + }) + .filter(this.filterUnauthorizedSpaceResults); + + if (authorizedSpaces.length === 0) { + this.legacyAuditLogger.spacesAuthorizationFailure(username, 'getAll'); + throw Boom.forbidden(); // Note: there is a catch for this in `SpacesSavedObjectsClient.find`; if we get rid of this error, remove that too + } + + const authorizedSpaceIds = authorizedSpaces.map((space) => space.id); + this.legacyAuditLogger.spacesAuthorizationSuccess(username, 'getAll', authorizedSpaceIds); + + return authorizedSpaces; + } + + public async get(id: string) { + if (this.useRbac) { + await this.ensureAuthorizedAtSpace( + id, + this.authorization.actions.login, + 'get', + `Unauthorized to get ${id} space` + ); + } + + return this.spacesClient.get(id); + } + + public async create(space: Space) { + if (this.useRbac) { + await this.ensureAuthorizedGlobally( + this.authorization.actions.space.manage, + 'create', + 'Unauthorized to create spaces' + ); + } + + return this.spacesClient.create(space); + } + + public async update(id: string, space: Space) { + if (this.useRbac) { + await this.ensureAuthorizedGlobally( + this.authorization.actions.space.manage, + 'update', + 'Unauthorized to update spaces' + ); + } + + return this.spacesClient.update(id, space); + } + + public async delete(id: string) { + if (this.useRbac) { + await this.ensureAuthorizedGlobally( + this.authorization.actions.space.manage, + 'delete', + 'Unauthorized to delete spaces' + ); + } + + return this.spacesClient.delete(id); + } + + private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action }); + + if (hasAllRequested) { + this.legacyAuditLogger.spacesAuthorizationSuccess(username, method); + } else { + this.legacyAuditLogger.spacesAuthorizationFailure(username, method); + throw Boom.forbidden(forbiddenMessage); + } + } + + private async ensureAuthorizedAtSpace( + spaceId: string, + action: string, + method: string, + forbiddenMessage: string + ) { + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, { + kibana: action, + }); + + if (hasAllRequested) { + this.legacyAuditLogger.spacesAuthorizationSuccess(username, method, [spaceId]); + } else { + this.legacyAuditLogger.spacesAuthorizationFailure(username, method, [spaceId]); + throw Boom.forbidden(forbiddenMessage); + } + } + + private filterUnauthorizedSpaceResults(value: GetSpaceResult | null): value is GetSpaceResult { + return value !== null; + } +} diff --git a/x-pack/plugins/security/server/spaces/setup_spaces_client.test.ts b/x-pack/plugins/security/server/spaces/setup_spaces_client.test.ts new file mode 100644 index 0000000000000..ee17f366583ba --- /dev/null +++ b/x-pack/plugins/security/server/spaces/setup_spaces_client.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { coreMock, httpServerMock } from '../../../../../src/core/server/mocks'; + +import { spacesMock } from '../../../spaces/server/mocks'; + +import { auditServiceMock } from '../audit/index.mock'; +import { authorizationMock } from '../authorization/index.mock'; +import { setupSpacesClient } from './setup_spaces_client'; + +describe('setupSpacesClient', () => { + it('does not setup the spaces client when spaces is disabled', () => { + const authz = authorizationMock.create(); + const audit = auditServiceMock.create(); + + setupSpacesClient({ authz, audit }); + + expect(audit.getLogger).not.toHaveBeenCalled(); + }); + + it('configures the repository factory, wrapper, and audit logger', () => { + const authz = authorizationMock.create(); + const audit = auditServiceMock.create(); + const spaces = spacesMock.createSetup(); + + setupSpacesClient({ authz, audit, spaces }); + + expect(spaces.spacesClient.registerClientWrapper).toHaveBeenCalledTimes(1); + expect(spaces.spacesClient.setClientRepositoryFactory).toHaveBeenCalledTimes(1); + expect(audit.getLogger).toHaveBeenCalledTimes(1); + }); + + it('creates a factory that creates an internal repository when RBAC is used for the request', () => { + const authz = authorizationMock.create(); + const audit = auditServiceMock.create(); + const spaces = spacesMock.createSetup(); + + const { savedObjects } = coreMock.createStart(); + + setupSpacesClient({ authz, audit, spaces }); + + expect(spaces.spacesClient.setClientRepositoryFactory).toHaveBeenCalledTimes(1); + const [repositoryFactory] = spaces.spacesClient.setClientRepositoryFactory.mock.calls[0]; + + const request = httpServerMock.createKibanaRequest(); + authz.mode.useRbacForRequest.mockReturnValueOnce(true); + + repositoryFactory(request, savedObjects); + + expect(savedObjects.createInternalRepository).toHaveBeenCalledTimes(1); + expect(savedObjects.createInternalRepository).toHaveBeenCalledWith(['space']); + expect(savedObjects.createScopedRepository).not.toHaveBeenCalled(); + }); + + it('creates a factory that creates a scoped repository when RBAC is NOT used for the request', () => { + const authz = authorizationMock.create(); + const audit = auditServiceMock.create(); + const spaces = spacesMock.createSetup(); + + const { savedObjects } = coreMock.createStart(); + + setupSpacesClient({ authz, audit, spaces }); + + expect(spaces.spacesClient.setClientRepositoryFactory).toHaveBeenCalledTimes(1); + const [repositoryFactory] = spaces.spacesClient.setClientRepositoryFactory.mock.calls[0]; + + const request = httpServerMock.createKibanaRequest(); + authz.mode.useRbacForRequest.mockReturnValueOnce(false); + + repositoryFactory(request, savedObjects); + + expect(savedObjects.createInternalRepository).not.toHaveBeenCalled(); + expect(savedObjects.createScopedRepository).toHaveBeenCalledTimes(1); + expect(savedObjects.createScopedRepository).toHaveBeenCalledWith(request, ['space']); + }); +}); diff --git a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts new file mode 100644 index 0000000000000..f9b105d630516 --- /dev/null +++ b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts @@ -0,0 +1,38 @@ +/* + * 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 { SpacesPluginSetup } from '../../../spaces/server'; +import { AuditServiceSetup } from '../audit'; +import { AuthorizationServiceSetup } from '../authorization'; +import { LegacySpacesAuditLogger } from './legacy_audit_logger'; +import { SecureSpacesClientWrapper } from './secure_spaces_client_wrapper'; + +interface Deps { + audit: AuditServiceSetup; + authz: AuthorizationServiceSetup; + spaces?: SpacesPluginSetup; +} + +export const setupSpacesClient = ({ audit, authz, spaces }: Deps) => { + if (!spaces) { + return; + } + const { spacesClient } = spaces; + + spacesClient.setClientRepositoryFactory((request, savedObjectsStart) => { + if (authz.mode.useRbacForRequest(request)) { + return savedObjectsStart.createInternalRepository(['space']); + } + return savedObjectsStart.createScopedRepository(request, ['space']); + }); + + const spacesAuditLogger = new LegacySpacesAuditLogger(audit.getLogger()); + + spacesClient.registerClientWrapper( + (request, baseClient) => + new SecureSpacesClientWrapper(baseClient, request, authz, spacesAuditLogger) + ); +}; diff --git a/x-pack/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap b/x-pack/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap deleted file mode 100644 index d08be39f9282e..0000000000000 --- a/x-pack/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`addSpaceIdToPath it throws an error when the requested path does not start with a slash 1`] = `"path must start with a /"`; diff --git a/x-pack/plugins/spaces/common/lib/spaces_url_parser.test.ts b/x-pack/plugins/spaces/common/lib/spaces_url_parser.test.ts index 2b34bc77ec686..90486d499b947 100644 --- a/x-pack/plugins/spaces/common/lib/spaces_url_parser.test.ts +++ b/x-pack/plugins/spaces/common/lib/spaces_url_parser.test.ts @@ -102,6 +102,6 @@ describe('addSpaceIdToPath', () => { test('it throws an error when the requested path does not start with a slash', () => { expect(() => { addSpaceIdToPath('', '', 'foo'); - }).toThrowErrorMatchingSnapshot(); + }).toThrowErrorMatchingInlineSnapshot(`"path must start with a /"`); }); }); diff --git a/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts b/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts index 6466835899f16..e266af704e8b6 100644 --- a/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts +++ b/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts @@ -47,10 +47,12 @@ export function addSpaceIdToPath( throw new Error(`path must start with a /`); } + const normalizedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + if (spaceId && spaceId !== DEFAULT_SPACE_ID) { - return `${basePath}/s/${spaceId}${requestedPath}`; + return `${normalizedBasePath}/s/${spaceId}${requestedPath}`; } - return `${basePath}${requestedPath}`; + return `${normalizedBasePath}${requestedPath}` || '/'; } function stripServerBasePath(requestBasePath: string, serverBasePath: string) { diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json index 4443b6d8a685b..62a86409d8889 100644 --- a/x-pack/plugins/spaces/kibana.json +++ b/x-pack/plugins/spaces/kibana.json @@ -8,7 +8,6 @@ "advancedSettings", "home", "management", - "security", "usageCollection", "savedObjectsManagement" ], diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts index 42f3d766adf85..bc861964bf56d 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts @@ -116,12 +116,54 @@ describe('SpacesManager', () => { const result = await spacesManager.getShareSavedObjectPermissions('foo'); expect(coreStart.http.get).toHaveBeenCalledTimes(2); expect(coreStart.http.get).toHaveBeenLastCalledWith( - '/internal/spaces/_share_saved_object_permissions', + '/internal/security/_share_saved_object_permissions', { query: { type: 'foo' }, } ); expect(result).toEqual({ shareToAllSpaces }); }); + + it('allows the share if security is disabled', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.get.mockResolvedValueOnce({}); + coreStart.http.get.mockRejectedValueOnce({ + body: { + statusCode: 404, + }, + }); + const spacesManager = new SpacesManager(coreStart.http); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space + + const result = await spacesManager.getShareSavedObjectPermissions('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(2); + expect(coreStart.http.get).toHaveBeenLastCalledWith( + '/internal/security/_share_saved_object_permissions', + { + query: { type: 'foo' }, + } + ); + expect(result).toEqual({ shareToAllSpaces: true }); + }); + + it('throws all other errors', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.get.mockResolvedValueOnce({}); + coreStart.http.get.mockRejectedValueOnce(new Error('Get out of here!')); + const spacesManager = new SpacesManager(coreStart.http); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space + + await expect( + spacesManager.getShareSavedObjectPermissions('foo') + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Get out of here!"`); + + expect(coreStart.http.get).toHaveBeenCalledTimes(2); + expect(coreStart.http.get).toHaveBeenLastCalledWith( + '/internal/security/_share_saved_object_permissions', + { + query: { type: 'foo' }, + } + ); + }); }); }); diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index 8ddda7130d8b8..8e530ddf8ff2e 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -115,7 +115,16 @@ export class SpacesManager { public async getShareSavedObjectPermissions( type: string ): Promise<{ shareToAllSpaces: boolean }> { - return this.http.get('/internal/spaces/_share_saved_object_permissions', { query: { type } }); + return this.http + .get('/internal/security/_share_saved_object_permissions', { query: { type } }) + .catch((err) => { + const isNotFound = err?.body?.statusCode === 404; + if (isNotFound) { + // security is not enabled + return { shareToAllSpaces: true }; + } + throw err; + }); } public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index 0dd070e63ba31..bfd73984811ef 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -126,14 +126,14 @@ const setup = (space: Space) => { {}, ]); - const spacesService = spacesServiceMock.createSetupContract(); + const spacesService = spacesServiceMock.createStartContract(); spacesService.getActiveSpace.mockResolvedValue(space); const logger = loggingSystemMock.createLogger(); const switcher = setupCapabilitiesSwitcher( (coreSetup as unknown) as CoreSetup, - spacesService, + () => spacesService, logger ); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 8b0b955c40d92..ee059f7b9c26e 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -7,12 +7,12 @@ import _ from 'lodash'; import { Capabilities, CapabilitiesSwitcher, CoreSetup, Logger } from 'src/core/server'; import { KibanaFeature } from '../../../../plugins/features/server'; import { Space } from '../../common/model/space'; -import { SpacesServiceSetup } from '../spaces_service'; +import { SpacesServiceStart } from '../spaces_service'; import { PluginsStart } from '../plugin'; export function setupCapabilitiesSwitcher( core: CoreSetup, - spacesService: SpacesServiceSetup, + getSpacesService: () => SpacesServiceStart, logger: Logger ): CapabilitiesSwitcher { return async (request, capabilities) => { @@ -24,7 +24,7 @@ export function setupCapabilitiesSwitcher( try { const [activeSpace, [, { features }]] = await Promise.all([ - spacesService.getActiveSpace(request), + getSpacesService().getActiveSpace(request), core.getStartServices(), ]); diff --git a/x-pack/plugins/spaces/server/capabilities/index.ts b/x-pack/plugins/spaces/server/capabilities/index.ts index 56a72a2eeaf19..32620528682e4 100644 --- a/x-pack/plugins/spaces/server/capabilities/index.ts +++ b/x-pack/plugins/spaces/server/capabilities/index.ts @@ -8,13 +8,13 @@ import { CoreSetup, Logger } from 'src/core/server'; import { capabilitiesProvider } from './capabilities_provider'; import { setupCapabilitiesSwitcher } from './capabilities_switcher'; import { PluginsStart } from '../plugin'; -import { SpacesServiceSetup } from '../spaces_service'; +import { SpacesServiceStart } from '../spaces_service'; export const setupCapabilities = ( core: CoreSetup, - spacesService: SpacesServiceSetup, + getSpacesService: () => SpacesServiceStart, logger: Logger ) => { core.capabilities.registerProvider(capabilitiesProvider); - core.capabilities.registerSwitcher(setupCapabilitiesSwitcher(core, spacesService, logger)); + core.capabilities.registerSwitcher(setupCapabilitiesSwitcher(core, getSpacesService, logger)); }; diff --git a/x-pack/plugins/spaces/server/index.ts b/x-pack/plugins/spaces/server/index.ts index 77eb3e9c73980..85f1facf6131c 100644 --- a/x-pack/plugins/spaces/server/index.ts +++ b/x-pack/plugins/spaces/server/index.ts @@ -13,10 +13,13 @@ import { Plugin } from './plugin'; // reduce number of such exports to zero and provide everything we want to expose via Setup/Start // run-time contracts. +export { addSpaceIdToPath } from '../common'; + // end public contract exports -export { SpacesPluginSetup } from './plugin'; -export { SpacesServiceSetup } from './spaces_service'; +export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; +export { SpacesServiceSetup, SpacesServiceStart } from './spaces_service'; +export { ISpacesClient } from './spaces_client'; export { Space } from '../common/model/space'; export const config = { schema: ConfigSchema }; diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 89371259ae04c..ec540a08c07b9 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -3,7 +3,6 @@ * 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 Boom from '@hapi/boom'; import { Legacy } from 'kibana'; // @ts-ignore @@ -22,13 +21,11 @@ import { } from '../../../../../../src/core/server/mocks'; import * as kbnTestServer from '../../../../../../src/core/test_helpers/kbn_server'; import { SpacesService } from '../../spaces_service'; -import { SpacesAuditLogger } from '../audit_logger'; import { convertSavedObjectToSpace } from '../../routes/lib'; import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor'; import { KibanaFeature } from '../../../../features/server'; -import { spacesConfig } from '../__fixtures__'; -import { securityMock } from '../../../../security/server/mocks'; import { featuresPluginMock } from '../../../../features/server/mocks'; +import { spacesClientServiceMock } from '../../spaces_client/spaces_client_service.mock'; // FLAKY: https://github.com/elastic/kibana/issues/55953 describe.skip('onPostAuthInterceptor', () => { @@ -166,17 +163,18 @@ describe.skip('onPostAuthInterceptor', () => { coreStart.savedObjects.createInternalRepository.mockImplementation(mockRepository); coreStart.savedObjects.createScopedRepository.mockImplementation(mockRepository); - const service = new SpacesService(loggingMock); + const service = new SpacesService(); - const spacesService = await service.setup({ - http: (http as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + service.setup({ + basePath: http.basePath, + }); + + const spacesServiceStart = service.start({ + basePath: http.basePath, + spacesClientService: spacesClientServiceMock.createStart(), }); - spacesService.scopedClient = jest.fn().mockResolvedValue({ + spacesServiceStart.createSpacesClient = jest.fn().mockReturnValue({ getAll() { if (testOptions.simulateGetSpacesFailure) { throw Boom.unauthorized('missing credendials', 'Protected Elasticsearch'); @@ -206,7 +204,7 @@ describe.skip('onPostAuthInterceptor', () => { http: (http as unknown) as CoreSetup['http'], log: loggingMock, features: featuresPlugin, - spacesService, + getSpacesService: () => spacesServiceStart, }); const router = http.createRouter('/'); @@ -221,7 +219,7 @@ describe.skip('onPostAuthInterceptor', () => { return { response, - spacesService, + spacesService: spacesServiceStart, }; } @@ -342,7 +340,7 @@ describe.skip('onPostAuthInterceptor', () => { } `); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -381,7 +379,7 @@ describe.skip('onPostAuthInterceptor', () => { } `); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -414,7 +412,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(302); expect(response.header.location).toEqual(`/spaces/space_selector`); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -447,7 +445,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(302); expect(response.header.location).toEqual(`/s/a-space/spaces/enter`); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -473,7 +471,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(302); expect(response.header.location).toEqual(`/s/a-space/spaces/enter`); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -501,7 +499,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(302); expect(response.header.location).toEqual('/spaces/enter'); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -526,7 +524,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(200); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -551,7 +549,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(200); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, @@ -576,7 +574,7 @@ describe.skip('onPostAuthInterceptor', () => { expect(response.status).toEqual(404); - expect(spacesService.scopedClient).toHaveBeenCalledWith( + expect(spacesService.createSpacesClient).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ authorization: headers.authorization, diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts index 1aa2011a15b35..4731ddbac10c3 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts @@ -6,7 +6,7 @@ import { Logger, CoreSetup } from 'src/core/server'; import { Space } from '../../../common/model/space'; import { wrapError } from '../errors'; -import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; +import { SpacesServiceStart } from '../../spaces_service/spaces_service'; import { PluginsSetup } from '../../plugin'; import { getSpaceSelectorUrl } from '../get_space_selector_url'; import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants'; @@ -15,13 +15,13 @@ import { addSpaceIdToPath } from '../../../common'; export interface OnPostAuthInterceptorDeps { http: CoreSetup['http']; features: PluginsSetup['features']; - spacesService: SpacesServiceSetup; + getSpacesService: () => SpacesServiceStart; log: Logger; } export function initSpacesOnPostAuthRequestInterceptor({ features, - spacesService, + getSpacesService, log, http, }: OnPostAuthInterceptorDeps) { @@ -30,6 +30,8 @@ export function initSpacesOnPostAuthRequestInterceptor({ const path = request.url.pathname; + const spacesService = getSpacesService(); + const spaceId = spacesService.getSpaceId(request); // The root of kibana is also the root of the defaut space, @@ -43,7 +45,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ // which is not available at the time of "onRequest". if (isRequestingKibanaRoot) { try { - const spacesClient = await spacesService.scopedClient(request); + const spacesClient = spacesService.createSpacesClient(request); const spaces = await spacesClient.getAll(); if (spaces.length === 1) { @@ -76,7 +78,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ try { log.debug(`Verifying access to space "${spaceId}"`); - const spacesClient = await spacesService.scopedClient(request); + const spacesClient = spacesService.createSpacesClient(request); space = await spacesClient.get(spaceId); } catch (error) { const wrappedError = wrapError(error); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts deleted file mode 100644 index 095a9046d6d3b..0000000000000 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ /dev/null @@ -1,1237 +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 { SecurityPluginSetup } from '../../../../security/server'; -import { SpacesClient } from './spaces_client'; -import { ConfigType, ConfigSchema } from '../../config'; -import { GetAllSpacesPurpose } from '../../../common/model/types'; - -import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; -import { securityMock } from '../../../../security/server/mocks'; - -const createMockAuditLogger = () => { - return { - spacesAuthorizationFailure: jest.fn(), - spacesAuthorizationSuccess: jest.fn(), - }; -}; - -const createMockAuthorization = () => { - const mockCheckPrivilegesAtSpace = jest.fn(); - const mockCheckPrivilegesAtSpaces = jest.fn(); - const mockCheckPrivilegesGlobally = jest.fn(); - - const mockAuthorization = securityMock.createSetup().authz; - mockAuthorization.checkPrivilegesWithRequest.mockImplementation(() => ({ - atSpaces: mockCheckPrivilegesAtSpaces, - atSpace: mockCheckPrivilegesAtSpace, - globally: mockCheckPrivilegesGlobally, - })); - (mockAuthorization.actions.savedObject.get as jest.MockedFunction< - typeof mockAuthorization.actions.savedObject.get - >).mockImplementation((featureId, ...uiCapabilityParts) => { - return `mockSavedObjectAction:${featureId}/${uiCapabilityParts.join('/')}`; - }); - (mockAuthorization.actions.ui.get as jest.MockedFunction< - typeof mockAuthorization.actions.ui.get - >).mockImplementation((featureId, ...uiCapabilityParts) => { - return `mockUiAction:${featureId}/${uiCapabilityParts.join('/')}`; - }); - - return { - mockCheckPrivilegesAtSpaces, - mockCheckPrivilegesAtSpace, - mockCheckPrivilegesGlobally, - mockAuthorization, - }; -}; - -const createMockConfig = (mockConfig: ConfigType = { maxSpaces: 1000, enabled: true }) => { - return ConfigSchema.validate(mockConfig); -}; - -const baseSetup = (authorization: boolean | null) => { - const mockAuditLogger = createMockAuditLogger(); - const mockAuthorizationAndFunctions = createMockAuthorization(); - if (authorization !== null) { - mockAuthorizationAndFunctions.mockAuthorization.mode.useRbacForRequest.mockReturnValue( - authorization - ); - } - const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); - const mockConfig = createMockConfig(); - const mockInternalRepository = savedObjectsRepositoryMock.create(); - const request = Symbol() as any; - const client = new SpacesClient( - mockAuditLogger as any, - jest.fn(), - authorization === null ? null : mockAuthorizationAndFunctions.mockAuthorization, - mockCallWithRequestRepository, - mockConfig, - mockInternalRepository, - request - ); - - return { - mockAuditLogger, - ...mockAuthorizationAndFunctions, - mockCallWithRequestRepository, - mockConfig, - mockInternalRepository, - request, - client, - }; -}; - -describe('#getAll', () => { - const savedObjects = [ - { - id: 'foo', - attributes: { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - }, - }, - { - id: 'bar', - attributes: { - name: 'bar-name', - description: 'bar-description', - bar: 'bar-bar', - }, - }, - { - id: 'baz', - attributes: { - name: 'baz-name', - description: 'baz-description', - bar: 'baz-bar', - }, - }, - ]; - - const expectedSpaces = [ - { - id: 'foo', - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - }, - { - id: 'bar', - name: 'bar-name', - description: 'bar-description', - bar: 'bar-bar', - }, - { - id: 'baz', - name: 'baz-name', - description: 'baz-description', - bar: 'baz-bar', - }, - ]; - - const setup = (authorization: boolean | null) => { - const result = baseSetup(authorization); - const { mockCallWithRequestRepository, mockInternalRepository } = result; - mockCallWithRequestRepository.find.mockResolvedValue({ saved_objects: savedObjects } as any); - mockInternalRepository.find.mockResolvedValue({ saved_objects: savedObjects } as any); - return result; - }; - - describe('authorization is null', () => { - test(`finds spaces using callWithRequestRepository`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, mockConfig, client } = setup(null); - const actualSpaces = await client.getAll(); - - expect(actualSpaces).toEqual(expectedSpaces); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe(`authorization.mode.useRbacForRequest returns false`, () => { - test(`finds spaces using callWithRequestRepository`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - mockConfig, - request, - client, - } = setup(false); - const actualSpaces = await client.getAll(); - - expect(actualSpaces).toEqual(expectedSpaces); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => { - const { mockAuthorization, client } = setup(false); - const purpose = 'invalid_purpose' as GetAllSpacesPurpose; - await expect(client.getAll({ purpose })).rejects.toThrowError( - 'unsupported space purpose: invalid_purpose' - ); - - expect(mockAuthorization.mode.useRbacForRequest).not.toHaveBeenCalled(); - }); - }); - - describe('useRbacForRequest is true', () => { - it('throws Boom.badRequest when an invalid purpose is provided', async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpaces, - mockInternalRepository, - client, - } = setup(true); - const purpose = 'invalid_purpose' as GetAllSpacesPurpose; - await expect(client.getAll({ purpose })).rejects.toThrowError( - 'unsupported space purpose: invalid_purpose' - ); - - expect(mockInternalRepository.find).not.toHaveBeenCalled(); - expect(mockAuthorization.mode.useRbacForRequest).not.toHaveBeenCalled(); - expect(mockAuthorization.checkPrivilegesWithRequest).not.toHaveBeenCalled(); - expect(mockCheckPrivilegesAtSpaces).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - [ - { - purpose: undefined, - expectedPrivileges: (mockAuthorization: SecurityPluginSetup['authz']) => [ - mockAuthorization.actions.login, - ], - }, - { - purpose: 'any' as GetAllSpacesPurpose, - expectedPrivileges: (mockAuthorization: SecurityPluginSetup['authz']) => [ - mockAuthorization.actions.login, - ], - }, - { - purpose: 'copySavedObjectsIntoSpace' as GetAllSpacesPurpose, - expectedPrivileges: () => [`mockUiAction:savedObjectsManagement/copyIntoSpace`], - }, - { - purpose: 'findSavedObjects' as GetAllSpacesPurpose, - expectedPrivileges: (mockAuthorization: SecurityPluginSetup['authz']) => [ - mockAuthorization.actions.login, - `mockSavedObjectAction:config/find`, - ], - }, - { - purpose: 'shareSavedObjectsIntoSpace' as GetAllSpacesPurpose, - expectedPrivileges: () => [`mockUiAction:savedObjectsManagement/shareIntoSpace`], - }, - ].forEach((scenario) => { - const { purpose } = scenario; - describe(`with purpose='${purpose}'`, () => { - test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpaces, - mockConfig, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - const privileges = scenario.expectedPrivileges(mockAuthorization); - mockCheckPrivilegesAtSpaces.mockReturnValue({ - username, - privileges: { - kibana: privileges - .map((privilege) => [ - { resource: savedObjects[0].id, privilege, authorized: false }, - { resource: savedObjects[1].id, privilege, authorized: false }, - { resource: savedObjects[2].id, privilege, authorized: false }, - ]) - .flat(), - }, - }); - await expect(client.getAll({ purpose })).rejects.toThrowError('Forbidden'); - - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledTimes(1); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( - savedObjects.map((savedObject) => savedObject.id), - { kibana: privileges } - ); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith( - username, - 'getAll' - ); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns spaces that the user is authorized for`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpaces, - mockConfig, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - const privileges = scenario.expectedPrivileges(mockAuthorization); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesAtSpaces.mockReturnValue({ - username, - privileges: { - kibana: privileges - .map((privilege) => [ - { resource: savedObjects[0].id, privilege, authorized: true }, - { resource: savedObjects[1].id, privilege, authorized: false }, - { resource: savedObjects[2].id, privilege, authorized: false }, - ]) - .flat(), - }, - }); - const actualSpaces = await client.getAll({ purpose }); - - expect(actualSpaces).toEqual([expectedSpaces[0]]); - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledTimes(1); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( - savedObjects.map((savedObject) => savedObject.id), - { kibana: privileges } - ); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'getAll', - [savedObjects[0].id] - ); - }); - }); - }); - }); - - describe('includeAuthorizedPurposes is true', () => { - const includeAuthorizedPurposes = true; - - ([ - 'any', - 'copySavedObjectsIntoSpace', - 'findSavedObjects', - 'shareSavedObjectsIntoSpace', - ] as GetAllSpacesPurpose[]).forEach((purpose) => { - describe(`with purpose='${purpose}'`, () => { - test('throws error', async () => { - const { client } = setup(null); - expect(client.getAll({ purpose, includeAuthorizedPurposes })).rejects.toThrowError( - `'purpose' cannot be supplied with 'includeAuthorizedPurposes'` - ); - }); - }); - }); - - describe('with purpose=undefined', () => { - describe('authorization is null', () => { - test(`finds spaces using callWithRequestRepository and returns unaugmented results`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, mockConfig, client } = setup( - null - ); - const actualSpaces = await client.getAll({ includeAuthorizedPurposes }); - - expect(actualSpaces).toEqual(expectedSpaces); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe(`authorization.mode.useRbacForRequest returns false`, () => { - test(`finds spaces using callWithRequestRepository and returns unaugmented results`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - mockConfig, - request, - client, - } = setup(false); - const actualSpaces = await client.getAll({ includeAuthorizedPurposes }); - - expect(actualSpaces).toEqual(expectedSpaces); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpaces, - mockConfig, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - const privileges = [ - mockAuthorization.actions.login, - `mockUiAction:savedObjectsManagement/copyIntoSpace`, - `mockSavedObjectAction:config/find`, - `mockUiAction:savedObjectsManagement/shareIntoSpace`, - ]; - mockCheckPrivilegesAtSpaces.mockReturnValue({ - username, - privileges: { - kibana: privileges - .map((privilege) => [ - { resource: savedObjects[0].id, privilege, authorized: false }, - { resource: savedObjects[1].id, privilege, authorized: false }, - { resource: savedObjects[2].id, privilege, authorized: false }, - ]) - .flat(), - }, - }); - await expect(client.getAll({ includeAuthorizedPurposes })).rejects.toThrowError( - 'Forbidden' - ); - - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledTimes(1); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( - savedObjects.map((savedObject) => savedObject.id), - { - kibana: [ - mockAuthorization.actions.login, - `mockUiAction:savedObjectsManagement/copyIntoSpace`, - mockAuthorization.actions.login, // the actual privilege check deduplicates this -- we mimicked that behavior in our mock result - `mockSavedObjectAction:config/find`, - `mockUiAction:savedObjectsManagement/shareIntoSpace`, - ], - } - ); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith( - username, - 'getAll' - ); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns augmented spaces that the user is authorized for`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpaces, - mockConfig, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - const privileges = [ - mockAuthorization.actions.login, - `mockUiAction:savedObjectsManagement/copyIntoSpace`, - `mockSavedObjectAction:config/find`, - `mockUiAction:savedObjectsManagement/shareIntoSpace`, - ]; - mockCheckPrivilegesAtSpaces.mockReturnValue({ - username, - privileges: { - kibana: [ - ...privileges.map((privilege) => { - return { resource: savedObjects[0].id, privilege, authorized: true }; - }), - { - resource: savedObjects[1].id, - privilege: mockAuthorization.actions.login, - authorized: false, - }, - { - resource: savedObjects[1].id, - privilege: `mockUiAction:savedObjectsManagement/copyIntoSpace`, - authorized: false, - }, - { - resource: savedObjects[1].id, - privilege: `mockSavedObjectAction:config/find`, - authorized: true, // special case -- this alone will not authorize the user for the 'findSavedObjects purpose, since it also requires the login action - }, - { - resource: savedObjects[1].id, - privilege: `mockUiAction:savedObjectsManagement/shareIntoSpace`, - authorized: true, // note that this being authorized without the login action is contrived for this test case, and would never happen in a real world scenario - }, - ...privileges.map((privilege) => { - return { resource: savedObjects[2].id, privilege, authorized: false }; - }), - ], - }, - }); - const actualSpaces = await client.getAll({ includeAuthorizedPurposes }); - - expect(actualSpaces).toEqual([ - { - ...expectedSpaces[0], - authorizedPurposes: { - any: true, - copySavedObjectsIntoSpace: true, - findSavedObjects: true, - shareSavedObjectsIntoSpace: true, - }, - }, - { - ...expectedSpaces[1], - authorizedPurposes: { - any: false, - copySavedObjectsIntoSpace: false, - findSavedObjects: false, - shareSavedObjectsIntoSpace: true, - }, - }, - ]); - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: mockConfig.maxSpaces, - sortField: 'name.keyword', - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledTimes(1); - expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( - savedObjects.map((savedObject) => savedObject.id), - { - kibana: [ - mockAuthorization.actions.login, - `mockUiAction:savedObjectsManagement/copyIntoSpace`, - mockAuthorization.actions.login, // the actual privilege check deduplicates this -- we mimicked that behavior in our mock result - `mockSavedObjectAction:config/find`, - `mockUiAction:savedObjectsManagement/shareIntoSpace`, - ], - } - ); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'getAll', - [savedObjects[0].id, savedObjects[1].id] - ); - }); - }); - }); - }); -}); - -describe('#get', () => { - const savedObject = { - id: 'foo', - attributes: { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - }, - }; - - const expectedSpace = { - id: 'foo', - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - }; - - const setup = (authorization: boolean | null) => { - const result = baseSetup(authorization); - const { mockCallWithRequestRepository, mockInternalRepository } = result; - mockCallWithRequestRepository.get.mockResolvedValue(savedObject as any); - mockInternalRepository.get.mockResolvedValue(savedObject as any); - return result; - }; - - describe(`authorization is null`, () => { - test(`gets space using callWithRequestRepository`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, client } = setup(null); - const id = savedObject.id; - const actualSpace = await client.get(id); - - expect(actualSpace).toEqual(expectedSpace); - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe(`authorization.mode.useRbacForRequest returns false`, () => { - test(`gets space using callWithRequestRepository`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - request, - client, - } = setup(false); - const id = savedObject.id; - const actualSpace = await client.get(id); - - expect(actualSpace).toEqual(expectedSpace); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`throws Boom.forbidden if the user isn't authorized at space`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpace, - request, - client, - } = setup(true); - const username = Symbol(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesAtSpace.mockReturnValue({ - username, - hasAllRequested: false, - }); - const id = 'foo-space'; - - await expect(client.get(id)).rejects.toThrowError('Unauthorized to get foo-space space'); - - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, { - kibana: mockAuthorization.actions.login, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'get', [ - id, - ]); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns space using internalRepository if the user is authorized at space`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesAtSpace, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesAtSpace.mockReturnValue({ - username, - hasAllRequested: true, - }); - const id = savedObject.id; - - const space = await client.get(id); - - expect(space).toEqual(expectedSpace); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, { - kibana: mockAuthorization.actions.login, - }); - expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [ - id, - ]); - }); - }); -}); - -describe('#create', () => { - const id = 'foo'; - - const spaceToCreate = { - id, - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - _reserved: true, - disabledFeatures: [], - }; - - const attributes = { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - disabledFeatures: [], - }; - - const savedObject = { - id, - attributes: { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - disabledFeatures: [], - }, - }; - - const expectedReturnedSpace = { - id, - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - disabledFeatures: [], - }; - - const setup = (authorization: boolean | null) => { - const result = baseSetup(authorization); - const { mockCallWithRequestRepository, mockInternalRepository } = result; - mockCallWithRequestRepository.create.mockResolvedValue(savedObject as any); - mockInternalRepository.create.mockResolvedValue(savedObject as any); - return result; - }; - - describe(`authorization is null`, () => { - test(`creates space using callWithRequestRepository when we're under the max`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, mockConfig, client } = setup(null); - mockCallWithRequestRepository.find.mockResolvedValue({ - total: mockConfig.maxSpaces - 1, - } as any); - const actualSpace = await client.create(spaceToCreate); - - expect(actualSpace).toEqual(expectedReturnedSpace); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: 0, - }); - expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith('space', attributes, { - id, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws bad request when we are at the maximum number of spaces`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, mockConfig, client } = setup(null); - mockCallWithRequestRepository.find.mockResolvedValue({ total: mockConfig.maxSpaces } as any); - await expect(client.create(spaceToCreate)).rejects.toThrowError( - 'Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting' - ); - - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: 0, - }); - expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe(`authorization.mode.useRbacForRequest returns false`, () => { - test(`creates space using callWithRequestRepository when we're under the max`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - mockConfig, - request, - client, - } = setup(false); - mockCallWithRequestRepository.find.mockResolvedValue({ - total: mockConfig.maxSpaces - 1, - } as any); - const actualSpace = await client.create(spaceToCreate); - - expect(actualSpace).toEqual(expectedReturnedSpace); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: 0, - }); - expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith('space', attributes, { - id, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws bad request when we're at the maximum number of spaces`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - mockConfig, - request, - client, - } = setup(false); - mockCallWithRequestRepository.find.mockResolvedValue({ total: mockConfig.maxSpaces } as any); - await expect(client.create(spaceToCreate)).rejects.toThrowError( - 'Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting' - ); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: 0, - }); - expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`throws Boom.forbidden if the user isn't authorized at space`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - request, - client, - } = setup(true); - const username = Symbol(); - mockCheckPrivilegesGlobally.mockReturnValue({ username, hasAllRequested: false }); - await expect(client.create(spaceToCreate)).rejects.toThrowError( - 'Unauthorized to create spaces' - ); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'create'); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`creates space using internalRepository if the user is authorized and we're under the max`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - mockConfig, - mockInternalRepository, - request, - client, - } = setup(true); - mockInternalRepository.find.mockResolvedValue({ - total: mockConfig.maxSpaces - 1, - } as any); - const username = Symbol(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ username, hasAllRequested: true }); - const actualSpace = await client.create(spaceToCreate); - - expect(actualSpace).toEqual(expectedReturnedSpace); - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: 0, - }); - expect(mockInternalRepository.create).toHaveBeenCalledWith('space', attributes, { - id, - }); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'create'); - }); - - test(`throws bad request when we are at the maximum number of spaces`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - mockConfig, - mockInternalRepository, - request, - client, - } = setup(true); - mockInternalRepository.find.mockResolvedValue({ total: mockConfig.maxSpaces } as any); - const username = Symbol(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ username, hasAllRequested: true }); - await expect(client.create(spaceToCreate)).rejects.toThrowError( - 'Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting' - ); - - expect(mockInternalRepository.find).toHaveBeenCalledWith({ - type: 'space', - page: 1, - perPage: 0, - }); - expect(mockInternalRepository.create).not.toHaveBeenCalled(); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'create'); - }); - }); -}); - -describe('#update', () => { - const spaceToUpdate = { - id: 'foo', - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - _reserved: false, - disabledFeatures: [], - }; - - const attributes = { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - disabledFeatures: [], - }; - - const savedObject = { - id: 'foo', - attributes: { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - _reserved: true, - disabledFeatures: [], - }, - }; - - const expectedReturnedSpace = { - id: 'foo', - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - _reserved: true, - disabledFeatures: [], - }; - - const setup = (authorization: boolean | null) => { - const result = baseSetup(authorization); - const { mockCallWithRequestRepository, mockInternalRepository } = result; - mockCallWithRequestRepository.get.mockResolvedValue(savedObject as any); - mockInternalRepository.get.mockResolvedValue(savedObject as any); - return result; - }; - - describe(`authorization is null`, () => { - test(`updates space using callWithRequestRepository`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, mockConfig, client } = setup(null); - mockCallWithRequestRepository.find.mockResolvedValue({ - total: mockConfig.maxSpaces - 1, - } as any); - const id = savedObject.id; - const actualSpace = await client.update(id, spaceToUpdate); - - expect(actualSpace).toEqual(expectedReturnedSpace); - expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, attributes); - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - describe(`authorization.mode.useRbacForRequest returns false`, () => { - test(`updates space using callWithRequestRepository`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - request, - client, - } = setup(false); - const id = savedObject.id; - const actualSpace = await client.update(id, spaceToUpdate); - - expect(actualSpace).toEqual(expectedReturnedSpace); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, attributes); - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`throws Boom.forbidden when user isn't authorized at space`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - request, - client, - } = setup(true); - const username = Symbol(); - mockCheckPrivilegesGlobally.mockReturnValue({ hasAllRequested: false, username }); - const id = savedObject.id; - await expect(client.update(id, spaceToUpdate)).rejects.toThrowError( - 'Unauthorized to update spaces' - ); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'update'); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`updates space using internalRepository if user is authorized`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - mockCheckPrivilegesGlobally.mockReturnValue({ hasAllRequested: true, username }); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - const id = savedObject.id; - const actualSpace = await client.update(id, spaceToUpdate); - - expect(actualSpace).toEqual(expectedReturnedSpace); - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockInternalRepository.update).toHaveBeenCalledWith('space', id, attributes); - expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'update'); - }); - }); -}); - -describe('#delete', () => { - const id = 'foo'; - - const reservedSavedObject = { - id, - attributes: { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - _reserved: true, - }, - }; - - const notReservedSavedObject = { - id, - attributes: { - name: 'foo-name', - description: 'foo-description', - bar: 'foo-bar', - }, - }; - - const setup = (authorization: boolean | null) => { - const result = baseSetup(authorization); - return result; - }; - - describe(`authorization is null`, () => { - test(`throws bad request when the space is reserved`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, client } = setup(null); - mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject as any); - await expect(client.delete(id)).rejects.toThrowError( - 'This Space cannot be deleted because it is reserved.' - ); - - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`deletes space using callWithRequestRepository when space isn't reserved`, async () => { - const { mockAuditLogger, mockCallWithRequestRepository, client } = setup(null); - mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject as any); - await client.delete(id); - - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id); - expect(mockCallWithRequestRepository.deleteByNamespace).toHaveBeenCalledWith(id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe(`authorization.mode.useRbacForRequest returns false`, () => { - test(`throws bad request when the space is reserved`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - request, - client, - } = setup(false); - mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject as any); - await expect(client.delete(id)).rejects.toThrowError( - 'This Space cannot be deleted because it is reserved.' - ); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`deletes space using callWithRequestRepository when space isn't reserved`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCallWithRequestRepository, - request, - client, - } = setup(false); - mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject as any); - await client.delete(id); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); - expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id); - expect(mockCallWithRequestRepository.deleteByNamespace).toHaveBeenCalledWith(id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('authorization.mode.useRbacForRequest returns true', () => { - test(`throws Boom.forbidden if the user isn't authorized`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - request, - client, - } = setup(true); - const username = Symbol(); - mockCheckPrivilegesGlobally.mockReturnValue({ username, hasAllRequested: false }); - await expect(client.delete(id)).rejects.toThrowError('Unauthorized to delete spaces'); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'delete'); - expect(mockAuditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws bad request if the user is authorized but the space is reserved`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - mockCheckPrivilegesGlobally.mockReturnValue({ username, hasAllRequested: true }); - mockInternalRepository.get.mockResolvedValue(reservedSavedObject as any); - await expect(client.delete(id)).rejects.toThrowError( - 'This Space cannot be deleted because it is reserved.' - ); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete'); - }); - - test(`deletes space using internalRepository if the user is authorized and the space isn't reserved`, async () => { - const { - mockAuditLogger, - mockAuthorization, - mockCheckPrivilegesGlobally, - mockInternalRepository, - request, - client, - } = setup(true); - const username = Symbol(); - mockCheckPrivilegesGlobally.mockReturnValue({ username, hasAllRequested: true }); - mockInternalRepository.get.mockResolvedValue(notReservedSavedObject as any); - await client.delete(id); - - expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); - expect(mockInternalRepository.delete).toHaveBeenCalledWith('space', id); - expect(mockInternalRepository.deleteByNamespace).toHaveBeenCalledWith(id); - expect(mockAuditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete'); - }); - }); -}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts deleted file mode 100644 index affe8724502d9..0000000000000 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ /dev/null @@ -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 Boom from '@hapi/boom'; -import { omit } from 'lodash'; -import { KibanaRequest } from 'src/core/server'; -import { SecurityPluginSetup } from '../../../../security/server'; -import { isReservedSpace } from '../../../common/is_reserved_space'; -import { Space } from '../../../common/model/space'; -import { SpacesAuditLogger } from '../audit_logger'; -import { ConfigType } from '../../config'; -import { GetAllSpacesPurpose, GetSpaceResult } from '../../../common/model/types'; - -interface GetAllSpacesOptions { - purpose?: GetAllSpacesPurpose; - includeAuthorizedPurposes?: boolean; -} - -const SUPPORTED_GET_SPACE_PURPOSES: GetAllSpacesPurpose[] = [ - 'any', - 'copySavedObjectsIntoSpace', - 'findSavedObjects', - 'shareSavedObjectsIntoSpace', -]; -const DEFAULT_PURPOSE = 'any'; - -const PURPOSE_PRIVILEGE_MAP: Record< - GetAllSpacesPurpose, - (authorization: SecurityPluginSetup['authz']) => string[] -> = { - any: (authorization) => [authorization.actions.login], - copySavedObjectsIntoSpace: (authorization) => [ - authorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), - ], - findSavedObjects: (authorization) => [ - authorization.actions.login, - authorization.actions.savedObject.get('config', 'find'), - ], - shareSavedObjectsIntoSpace: (authorization) => [ - authorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), - ], -}; - -function filterUnauthorizedSpaceResults(value: GetSpaceResult | null): value is GetSpaceResult { - return value !== null; -} - -export class SpacesClient { - constructor( - private readonly auditLogger: SpacesAuditLogger, - private readonly debugLogger: (message: string) => void, - private readonly authorization: SecurityPluginSetup['authz'] | null, - private readonly callWithRequestSavedObjectRepository: any, - private readonly config: ConfigType, - private readonly internalSavedObjectRepository: any, - private readonly request: KibanaRequest - ) {} - - public async getAll(options: GetAllSpacesOptions = {}): Promise { - const { purpose = DEFAULT_PURPOSE, includeAuthorizedPurposes = false } = options; - if (!SUPPORTED_GET_SPACE_PURPOSES.includes(purpose)) { - throw Boom.badRequest(`unsupported space purpose: ${purpose}`); - } - - if (options.purpose && includeAuthorizedPurposes) { - throw Boom.badRequest(`'purpose' cannot be supplied with 'includeAuthorizedPurposes'`); - } - - if (this.useRbac()) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { saved_objects } = await this.internalSavedObjectRepository.find({ - type: 'space', - page: 1, - perPage: this.config.maxSpaces, - sortField: 'name.keyword', - }); - - this.debugLogger(`SpacesClient.getAll(), using RBAC. Found ${saved_objects.length} spaces`); - - const spaces: GetSpaceResult[] = saved_objects.map(this.transformSavedObjectToSpace); - const spaceIds = spaces.map((space: Space) => space.id); - - const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - - // Collect all privileges which need to be checked - const allPrivileges = Object.entries(PURPOSE_PRIVILEGE_MAP).reduce( - (acc, [getSpacesPurpose, privilegeFactory]) => - !includeAuthorizedPurposes && getSpacesPurpose !== purpose - ? acc - : { ...acc, [getSpacesPurpose]: privilegeFactory(this.authorization!) }, - {} as Record - ); - - // Check all privileges against all spaces - const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, { - kibana: Object.values(allPrivileges).flat(), - }); - - // Determine which purposes the user is authorized for within each space. - // Remove any spaces for which user is fully unauthorized. - const checkHasAllRequired = (space: Space, actions: string[]) => - actions.every((action) => - privileges.kibana.some( - ({ resource, privilege, authorized }) => - resource === space.id && privilege === action && authorized - ) - ); - const authorizedSpaces = spaces - .map((space: Space) => { - if (!includeAuthorizedPurposes) { - // Check if the user is authorized for a single purpose - const requiredActions = PURPOSE_PRIVILEGE_MAP[purpose](this.authorization!); - return checkHasAllRequired(space, requiredActions) ? space : null; - } - - // Check if the user is authorized for each purpose - let hasAnyAuthorization = false; - const authorizedPurposes = Object.entries(PURPOSE_PRIVILEGE_MAP).reduce( - (acc, [purposeKey, privilegeFactory]) => { - const requiredActions = privilegeFactory(this.authorization!); - const hasAllRequired = checkHasAllRequired(space, requiredActions); - hasAnyAuthorization = hasAnyAuthorization || hasAllRequired; - return { ...acc, [purposeKey]: hasAllRequired }; - }, - {} as Record - ); - - if (!hasAnyAuthorization) { - return null; - } - return { ...space, authorizedPurposes }; - }) - .filter(filterUnauthorizedSpaceResults); - - if (authorizedSpaces.length === 0) { - this.debugLogger( - `SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces for ${purpose} purpose.` - ); - this.auditLogger.spacesAuthorizationFailure(username, 'getAll'); - throw Boom.forbidden(); // Note: there is a catch for this in `SpacesSavedObjectsClient.find`; if we get rid of this error, remove that too - } - - const authorizedSpaceIds = authorizedSpaces.map((s) => s.id); - this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorizedSpaceIds); - this.debugLogger( - `SpacesClient.getAll(), using RBAC. returning spaces: ${authorizedSpaceIds.join(',')}` - ); - return authorizedSpaces; - } else { - this.debugLogger(`SpacesClient.getAll(), NOT USING RBAC. querying all spaces`); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { saved_objects } = await this.callWithRequestSavedObjectRepository.find({ - type: 'space', - page: 1, - perPage: this.config.maxSpaces, - sortField: 'name.keyword', - }); - - this.debugLogger( - `SpacesClient.getAll(), NOT USING RBAC. Found ${saved_objects.length} spaces.` - ); - - return saved_objects.map(this.transformSavedObjectToSpace); - } - } - - public async get(id: string): Promise { - if (this.useRbac()) { - await this.ensureAuthorizedAtSpace( - id, - this.authorization!.actions.login, - 'get', - `Unauthorized to get ${id} space` - ); - } - const repository = this.useRbac() - ? this.internalSavedObjectRepository - : this.callWithRequestSavedObjectRepository; - - const savedObject = await repository.get('space', id); - return this.transformSavedObjectToSpace(savedObject); - } - - public async create(space: Space) { - if (this.useRbac()) { - this.debugLogger(`SpacesClient.create(), using RBAC. Checking if authorized globally`); - - await this.ensureAuthorizedGlobally( - this.authorization!.actions.space.manage, - 'create', - 'Unauthorized to create spaces' - ); - - this.debugLogger(`SpacesClient.create(), using RBAC. Global authorization check succeeded`); - } - const repository = this.useRbac() - ? this.internalSavedObjectRepository - : this.callWithRequestSavedObjectRepository; - - const { total } = await repository.find({ - type: 'space', - page: 1, - perPage: 0, - }); - if (total >= this.config.maxSpaces) { - throw Boom.badRequest( - 'Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting' - ); - } - - this.debugLogger(`SpacesClient.create(), using RBAC. Attempting to create space`); - - const attributes = omit(space, ['id', '_reserved']); - const id = space.id; - const createdSavedObject = await repository.create('space', attributes, { id }); - - this.debugLogger(`SpacesClient.create(), created space object`); - - return this.transformSavedObjectToSpace(createdSavedObject); - } - - public async update(id: string, space: Space) { - if (this.useRbac()) { - await this.ensureAuthorizedGlobally( - this.authorization!.actions.space.manage, - 'update', - 'Unauthorized to update spaces' - ); - } - const repository = this.useRbac() - ? this.internalSavedObjectRepository - : this.callWithRequestSavedObjectRepository; - - const attributes = omit(space, 'id', '_reserved'); - await repository.update('space', id, attributes); - const updatedSavedObject = await repository.get('space', id); - return this.transformSavedObjectToSpace(updatedSavedObject); - } - - public async delete(id: string) { - if (this.useRbac()) { - await this.ensureAuthorizedGlobally( - this.authorization!.actions.space.manage, - 'delete', - 'Unauthorized to delete spaces' - ); - } - - const repository = this.useRbac() - ? this.internalSavedObjectRepository - : this.callWithRequestSavedObjectRepository; - - const existingSavedObject = await repository.get('space', id); - if (isReservedSpace(this.transformSavedObjectToSpace(existingSavedObject))) { - throw Boom.badRequest('This Space cannot be deleted because it is reserved.'); - } - - await repository.deleteByNamespace(id); - - await repository.delete('space', id); - } - - private useRbac(): boolean { - return this.authorization != null && this.authorization.mode.useRbacForRequest(this.request); - } - - private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { - const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action }); - - if (hasAllRequested) { - this.auditLogger.spacesAuthorizationSuccess(username, method); - return; - } else { - this.auditLogger.spacesAuthorizationFailure(username, method); - throw Boom.forbidden(forbiddenMessage); - } - } - - private async ensureAuthorizedAtSpace( - spaceId: string, - action: string, - method: string, - forbiddenMessage: string - ) { - const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, { - kibana: action, - }); - - if (hasAllRequested) { - this.auditLogger.spacesAuthorizationSuccess(username, method, [spaceId]); - return; - } else { - this.auditLogger.spacesAuthorizationFailure(username, method, [spaceId]); - throw Boom.forbidden(forbiddenMessage); - } - } - - private transformSavedObjectToSpace(savedObject: any): Space { - return { - id: savedObject.id, - ...savedObject.attributes, - } as Space; - } -} diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index 8ec2e6f978d81..e63850a96900d 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -4,31 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import { createSpacesTutorialContextFactory } from './spaces_tutorial_context_factory'; import { SpacesService } from '../spaces_service'; -import { SpacesAuditLogger } from './audit_logger'; -import { coreMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { coreMock, httpServerMock } from '../../../../../src/core/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; -import { spacesConfig } from './__fixtures__'; -import { securityMock } from '../../../security/server/mocks'; +import { spacesClientServiceMock } from '../spaces_client/spaces_client_service.mock'; -const log = loggingSystemMock.createLogger(); - -const service = new SpacesService(log); +const service = new SpacesService(); describe('createSpacesTutorialContextFactory', () => { it('should create a valid context factory', async () => { - const spacesService = spacesServiceMock.createSetupContract(); - expect(typeof createSpacesTutorialContextFactory(spacesService)).toEqual('function'); + const spacesService = spacesServiceMock.createStartContract(); + expect(typeof createSpacesTutorialContextFactory(() => spacesService)).toEqual('function'); }); it('should create context with the current space id for space my-space-id', async () => { - const spacesService = spacesServiceMock.createSetupContract('my-space-id'); - const contextFactory = createSpacesTutorialContextFactory(spacesService); + const spacesService = spacesServiceMock.createStartContract('my-space-id'); + const contextFactory = createSpacesTutorialContextFactory(() => spacesService); - const request = {}; + const request = httpServerMock.createKibanaRequest(); expect(contextFactory(request)).toEqual({ spaceId: 'my-space-id', @@ -37,16 +32,17 @@ describe('createSpacesTutorialContextFactory', () => { }); it('should create context with the current space id for the default space', async () => { - const spacesService = await service.setup({ - http: coreMock.createSetup().http, - getStartServices: async () => [coreMock.createStart(), {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + service.setup({ + basePath: coreMock.createSetup().http.basePath, }); - const contextFactory = createSpacesTutorialContextFactory(spacesService); - - const request = {}; + const contextFactory = createSpacesTutorialContextFactory(() => + service.start({ + basePath: coreMock.createStart().http.basePath, + spacesClientService: spacesClientServiceMock.createStart(), + }) + ); + + const request = httpServerMock.createKibanaRequest(); expect(contextFactory(request)).toEqual({ spaceId: DEFAULT_SPACE_ID, diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts index f89681b709949..af5b5490a28ef 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesServiceSetup } from '../spaces_service/spaces_service'; +import { KibanaRequest } from 'src/core/server'; +import { SpacesServiceStart } from '../spaces_service/spaces_service'; -export function createSpacesTutorialContextFactory(spacesService: SpacesServiceSetup) { - return function spacesTutorialContextFactory(request: any) { +export function createSpacesTutorialContextFactory(getSpacesService: () => SpacesServiceStart) { + return function spacesTutorialContextFactory(request: KibanaRequest) { + const spacesService = getSpacesService(); return { spaceId: spacesService.getSpaceId(request), isInDefaultSpace: spacesService.isInDefaultSpace(request), diff --git a/x-pack/plugins/spaces/server/mocks.ts b/x-pack/plugins/spaces/server/mocks.ts index 99d547a92eeb6..3ef3f954b328d 100644 --- a/x-pack/plugins/spaces/server/mocks.ts +++ b/x-pack/plugins/spaces/server/mocks.ts @@ -3,12 +3,25 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { spacesClientServiceMock } from './spaces_client/spaces_client_service.mock'; import { spacesServiceMock } from './spaces_service/spaces_service.mock'; function createSetupMock() { - return { spacesService: spacesServiceMock.createSetupContract() }; + return { + spacesService: spacesServiceMock.createSetupContract(), + spacesClient: spacesClientServiceMock.createSetup(), + }; +} + +function createStartMock() { + return { + spacesService: spacesServiceMock.createStartContract(), + }; } export const spacesMock = { createSetup: createSetupMock, + createStart: createStartMock, }; + +export { spacesClientMock } from './spaces_client/spaces_client.mock'; diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts index b650a114ed978..fad54ceaa882b 100644 --- a/x-pack/plugins/spaces/server/plugin.test.ts +++ b/x-pack/plugins/spaces/server/plugin.test.ts @@ -13,30 +13,30 @@ import { usageCollectionPluginMock } from '../../../../src/plugins/usage_collect describe('Spaces Plugin', () => { describe('#setup', () => { - it('can setup with all optional plugins disabled, exposing the expected contract', async () => { + it('can setup with all optional plugins disabled, exposing the expected contract', () => { const initializerContext = coreMock.createPluginInitializerContext({}); const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); const licensing = licensingMock.createSetup(); const plugin = new Plugin(initializerContext); - const spacesSetup = await plugin.setup(core, { features, licensing }); + const spacesSetup = plugin.setup(core, { features, licensing }); expect(spacesSetup).toMatchInlineSnapshot(` Object { + "spacesClient": Object { + "registerClientWrapper": [Function], + "setClientRepositoryFactory": [Function], + }, "spacesService": Object { - "getActiveSpace": [Function], - "getBasePath": [Function], "getSpaceId": [Function], - "isInDefaultSpace": [Function], "namespaceToSpaceId": [Function], - "scopedClient": [Function], "spaceIdToNamespace": [Function], }, } `); }); - it('registers the capabilities provider and switcher', async () => { + it('registers the capabilities provider and switcher', () => { const initializerContext = coreMock.createPluginInitializerContext({}); const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); @@ -44,13 +44,13 @@ describe('Spaces Plugin', () => { const plugin = new Plugin(initializerContext); - await plugin.setup(core, { features, licensing }); + plugin.setup(core, { features, licensing }); expect(core.capabilities.registerProvider).toHaveBeenCalledTimes(1); expect(core.capabilities.registerSwitcher).toHaveBeenCalledTimes(1); }); - it('registers the usage collector', async () => { + it('registers the usage collector', () => { const initializerContext = coreMock.createPluginInitializerContext({}); const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); @@ -60,12 +60,12 @@ describe('Spaces Plugin', () => { const plugin = new Plugin(initializerContext); - await plugin.setup(core, { features, licensing, usageCollection }); + plugin.setup(core, { features, licensing, usageCollection }); expect(usageCollection.getCollectorByType('spaces')).toBeDefined(); }); - it('registers the "space" saved object type and client wrapper', async () => { + it('registers the "space" saved object type and client wrapper', () => { const initializerContext = coreMock.createPluginInitializerContext({}); const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); @@ -73,7 +73,7 @@ describe('Spaces Plugin', () => { const plugin = new Plugin(initializerContext); - await plugin.setup(core, { features, licensing }); + plugin.setup(core, { features, licensing }); expect(core.savedObjects.registerType).toHaveBeenCalledWith({ name: 'space', @@ -90,4 +90,32 @@ describe('Spaces Plugin', () => { ); }); }); + + describe('#start', () => { + it('can start with all optional plugins disabled, exposing the expected contract', () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const coreSetup = coreMock.createSetup() as CoreSetup; + const features = featuresPluginMock.createSetup(); + const licensing = licensingMock.createSetup(); + + const plugin = new Plugin(initializerContext); + plugin.setup(coreSetup, { features, licensing }); + + const coreStart = coreMock.createStart(); + + const spacesStart = plugin.start(coreStart); + expect(spacesStart).toMatchInlineSnapshot(` + Object { + "spacesService": Object { + "createSpacesClient": [Function], + "getActiveSpace": [Function], + "getSpaceId": [Function], + "isInDefaultSpace": [Function], + "namespaceToSpaceId": [Function], + "spaceIdToNamespace": [Function], + }, + } + `); + }); + }); }); diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index a9ba5ac2dc6de..517fde6ecb41a 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -7,17 +7,20 @@ import { Observable } from 'rxjs'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { HomeServerPluginSetup } from 'src/plugins/home/server'; -import { CoreSetup, Logger, PluginInitializerContext } from '../../../../src/core/server'; +import { + CoreSetup, + CoreStart, + Logger, + PluginInitializerContext, +} from '../../../../src/core/server'; import { PluginSetupContract as FeaturesPluginSetup, PluginStartContract as FeaturesPluginStart, } from '../../features/server'; -import { SecurityPluginSetup } from '../../security/server'; import { LicensingPluginSetup } from '../../licensing/server'; -import { SpacesAuditLogger } from './lib/audit_logger'; import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory'; import { registerSpacesUsageCollector } from './usage_collection'; -import { SpacesService } from './spaces_service'; +import { SpacesService, SpacesServiceStart } from './spaces_service'; import { SpacesServiceSetup } from './spaces_service'; import { ConfigType } from './config'; import { initSpacesRequestInterceptors } from './lib/request_interceptors'; @@ -28,11 +31,15 @@ import { setupCapabilities } from './capabilities'; import { SpacesSavedObjectsService } from './saved_objects'; import { DefaultSpaceService } from './default_space'; import { SpacesLicenseService } from '../common/licensing'; +import { + SpacesClientRepositoryFactory, + SpacesClientService, + SpacesClientWrapper, +} from './spaces_client'; export interface PluginsSetup { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; - security?: SecurityPluginSetup; usageCollection?: UsageCollectionSetup; home?: HomeServerPluginSetup; } @@ -43,11 +50,17 @@ export interface PluginsStart { export interface SpacesPluginSetup { spacesService: SpacesServiceSetup; + spacesClient: { + setClientRepositoryFactory: (factory: SpacesClientRepositoryFactory) => void; + registerClientWrapper: (wrapper: SpacesClientWrapper) => void; + }; } -export class Plugin { - private readonly pluginId = 'spaces'; +export interface SpacesPluginStart { + spacesService: SpacesServiceStart; +} +export class Plugin { private readonly config$: Observable; private readonly kibanaIndexConfig$: Observable<{ kibana: { index: string } }>; @@ -56,32 +69,38 @@ export class Plugin { private readonly spacesLicenseService = new SpacesLicenseService(); + private readonly spacesClientService: SpacesClientService; + + private readonly spacesService: SpacesService; + + private spacesServiceStart?: SpacesServiceStart; + private defaultSpaceService?: DefaultSpaceService; constructor(initializerContext: PluginInitializerContext) { this.config$ = initializerContext.config.create(); this.kibanaIndexConfig$ = initializerContext.config.legacy.globalConfig$; this.log = initializerContext.logger.get(); + this.spacesService = new SpacesService(); + this.spacesClientService = new SpacesClientService((message) => this.log.debug(message)); } - public async start() {} - - public async setup( - core: CoreSetup, - plugins: PluginsSetup - ): Promise { - const service = new SpacesService(this.log); + public setup(core: CoreSetup, plugins: PluginsSetup): SpacesPluginSetup { + const spacesClientSetup = this.spacesClientService.setup({ config$: this.config$ }); - const spacesService = await service.setup({ - http: core.http, - getStartServices: core.getStartServices, - authorization: plugins.security ? plugins.security.authz : null, - auditLogger: new SpacesAuditLogger(plugins.security?.audit.getLogger(this.pluginId)), - config$: this.config$, + const spacesServiceSetup = this.spacesService.setup({ + basePath: core.http.basePath, }); + const getSpacesService = () => { + if (!this.spacesServiceStart) { + throw new Error('spaces service has not been initialized!'); + } + return this.spacesServiceStart; + }; + const savedObjectsService = new SpacesSavedObjectsService(); - savedObjectsService.setup({ core, spacesService }); + savedObjectsService.setup({ core, getSpacesService }); const { license } = this.spacesLicenseService.setup({ license$: plugins.licensing.license$ }); @@ -106,24 +125,23 @@ export class Plugin { log: this.log, getStartServices: core.getStartServices, getImportExportObjectLimit: core.savedObjects.getImportExportObjectLimit, - spacesService, - authorization: plugins.security ? plugins.security.authz : null, + getSpacesService, }); const internalRouter = core.http.createRouter(); initInternalSpacesApi({ internalRouter, - spacesService, + getSpacesService, }); initSpacesRequestInterceptors({ http: core.http, log: this.log, - spacesService, + getSpacesService, features: plugins.features, }); - setupCapabilities(core, spacesService, this.log); + setupCapabilities(core, getSpacesService, this.log); if (plugins.usageCollection) { registerSpacesUsageCollector(plugins.usageCollection, { @@ -133,18 +151,28 @@ export class Plugin { }); } - if (plugins.security) { - plugins.security.registerSpacesService(spacesService); - } - if (plugins.home) { plugins.home.tutorials.addScopedTutorialContextFactory( - createSpacesTutorialContextFactory(spacesService) + createSpacesTutorialContextFactory(getSpacesService) ); } return { - spacesService, + spacesClient: spacesClientSetup, + spacesService: spacesServiceSetup, + }; + } + + public start(core: CoreStart) { + const spacesClientStart = this.spacesClientService.start(core); + + this.spacesServiceStart = this.spacesService.start({ + basePath: core.http.basePath, + spacesClientService: spacesClientStart, + }); + + return { + spacesService: this.spacesServiceStart, }; } diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts index 86db8a2eb2000..f1e641382452e 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; +import { ISavedObjectsRepository, SavedObjectsErrorHelpers } from 'src/core/server'; export const createMockSavedObjectsRepository = (spaces: any[] = []) => { const mockSavedObjectsClientContract = ({ @@ -37,7 +37,7 @@ export const createMockSavedObjectsRepository = (spaces: any[] = []) => { return {}; }), deleteByNamespace: jest.fn(), - } as unknown) as jest.Mocked; + } as unknown) as jest.Mocked; return mockSavedObjectsClientContract; }; diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index 341e5cf3bfbe0..a6e1c11d011a0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -14,7 +14,7 @@ import { createResolveSavedObjectsImportErrorsMock, createMockSavedObjectsService, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingSystemMock, httpServiceMock, @@ -22,11 +22,8 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { initCopyToSpacesApi } from './copy_to_space'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; import { ObjectType } from '@kbn/config-schema'; jest.mock('../../../../../../../src/core/server', () => { return { @@ -41,6 +38,7 @@ import { importSavedObjectsFromStream, resolveSavedObjectsImportErrors, } from '../../../../../../../src/core/server'; +import { SpacesClientService } from '../../../spaces_client'; describe('copy to space', () => { const spacesSavedObjects = createSpaces(); @@ -74,27 +72,21 @@ describe('copy to space', () => { const { savedObjects } = createMockSavedObjectsService(spaces); coreStart.savedObjects = savedObjects; - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, }); initCopyToSpacesApi({ @@ -102,8 +94,7 @@ describe('copy to space', () => { getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization: null, // not needed for this route + getSpacesService: () => spacesServiceStart, }); const [ diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index fef1646067fde..989c513ac00bc 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -21,7 +21,7 @@ const areObjectsUnique = (objects: SavedObjectIdentifier[]) => _.uniqBy(objects, (o: SavedObjectIdentifier) => `${o.type}:${o.id}`).length === objects.length; export function initCopyToSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, spacesService, getImportExportObjectLimit, getStartServices } = deps; + const { externalRouter, getSpacesService, getImportExportObjectLimit, getStartServices } = deps; externalRouter.post( { @@ -90,7 +90,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { overwrite, createNewCopies, } = request.body; - const sourceSpaceId = spacesService.getSpaceId(request); + const sourceSpaceId = getSpacesService().getSpaceId(request); const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { objects, includeReferences, @@ -155,7 +155,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { request ); const { objects, includeReferences, retries, createNewCopies } = request.body; - const sourceSpaceId = spacesService.getSpaceId(request); + const sourceSpaceId = getSpacesService().getSpaceId(request); const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( sourceSpaceId, { diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index 4fe81027c3508..c9b5fc96094cb 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -12,7 +12,6 @@ import { mockRouteContextWithInvalidLicense, } from '../__fixtures__'; import { - CoreSetup, kibanaResponseFactory, RouteValidatorConfig, SavedObjectsErrorHelpers, @@ -24,12 +23,10 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { initDeleteSpacesApi } from './delete'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; import { ObjectType } from '@kbn/config-schema'; +import { SpacesClientService } from '../../../spaces_client'; describe('Spaces Public API', () => { const spacesSavedObjects = createSpaces(); @@ -44,27 +41,21 @@ describe('Spaces Public API', () => { const coreStart = coreMock.createStart(); - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, }); initDeleteSpacesApi({ @@ -72,8 +63,7 @@ describe('Spaces Public API', () => { getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization: null, // not needed for this route + getSpacesService: () => spacesServiceStart, }); const [routeDefinition, routeHandler] = router.delete.mock.calls[0]; @@ -186,6 +176,6 @@ describe('Spaces Public API', () => { const { status, payload } = response; expect(status).toEqual(400); - expect(payload.message).toEqual('This Space cannot be deleted because it is reserved.'); + expect(payload.message).toEqual('The default space cannot be deleted because it is reserved.'); }); }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.ts index 81e643bf5ede8..794698fd91cb0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.ts @@ -8,12 +8,11 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import { SavedObjectsErrorHelpers } from '../../../../../../../src/core/server'; import { wrapError } from '../../../lib/errors'; -import { SpacesClient } from '../../../lib/spaces_client'; import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initDeleteSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, log, spacesService } = deps; + const { externalRouter, log, getSpacesService } = deps; externalRouter.delete( { @@ -25,7 +24,7 @@ export function initDeleteSpacesApi(deps: ExternalRouteDeps) { }, }, createLicensedRouteHandler(async (context, request, response) => { - const spacesClient: SpacesClient = await spacesService.scopedClient(request); + const spacesClient = getSpacesService().createSpacesClient(request); const id = request.params.id; diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index 4786399936662..6fa26a7bcd557 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -11,7 +11,7 @@ import { mockRouteContext, } from '../__fixtures__'; import { initGetSpaceApi } from './get'; -import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; import { loggingSystemMock, httpServiceMock, @@ -19,10 +19,8 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; +import { SpacesClientService } from '../../../spaces_client'; describe('GET space', () => { const spacesSavedObjects = createSpaces(); @@ -38,27 +36,21 @@ describe('GET space', () => { const log = loggingSystemMock.create().get('spaces'); - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, }); initGetSpaceApi({ @@ -66,8 +58,7 @@ describe('GET space', () => { getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization: null, // not needed for this route + getSpacesService: () => spacesServiceStart, }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.ts b/x-pack/plugins/spaces/server/routes/api/external/get.ts index 150c9f05156a2..2644e74ec4bf9 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.ts @@ -11,7 +11,7 @@ import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initGetSpaceApi(deps: ExternalRouteDeps) { - const { externalRouter, spacesService } = deps; + const { externalRouter, getSpacesService } = deps; externalRouter.get( { @@ -24,7 +24,7 @@ export function initGetSpaceApi(deps: ExternalRouteDeps) { }, createLicensedRouteHandler(async (context, request, response) => { const spaceId = request.params.id; - const spacesClient = await spacesService.scopedClient(request); + const spacesClient = getSpacesService().createSpacesClient(request); try { const space = await spacesClient.get(spaceId); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index 81746c9db53c4..5b24a33cb014d 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -10,7 +10,7 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; import { loggingSystemMock, httpServiceMock, @@ -18,11 +18,10 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { initGetAllSpacesApi } from './get_all'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; +import { ObjectType } from '@kbn/config-schema'; +import { SpacesClientService } from '../../../spaces_client'; describe('GET /spaces/space', () => { const spacesSavedObjects = createSpaces(); @@ -38,27 +37,21 @@ describe('GET /spaces/space', () => { const log = loggingSystemMock.create().get('spaces'); - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, }); initGetAllSpacesApi({ @@ -66,11 +59,11 @@ describe('GET /spaces/space', () => { getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization: null, // not needed for this route + getSpacesService: () => spacesServiceStart, }); return { + routeConfig: router.get.mock.calls[0][0], routeHandler: router.get.mock.calls[0][1], }; }; @@ -89,21 +82,27 @@ describe('GET /spaces/space', () => { }); it(`returns expected result when specifying include_authorized_purposes=true`, async () => { - const { routeHandler } = await setup(); + const { routeConfig, routeHandler } = await setup(); const request = httpServerMock.createKibanaRequest({ method: 'get', query: { purpose, include_authorized_purposes: true }, }); + + if (routeConfig.validate === false) { + throw new Error('Test setup failure. Expected route validation'); + } + const queryParamsValidation = routeConfig.validate.query! as ObjectType; + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); if (purpose === undefined) { + expect(() => queryParamsValidation.validate(request.query)).not.toThrow(); expect(response.status).toEqual(200); expect(response.payload).toEqual(spaces); } else { - expect(response.status).toEqual(400); - expect(response.payload).toEqual( - new Error(`'purpose' cannot be supplied with 'includeAuthorizedPurposes'`) + expect(() => queryParamsValidation.validate(request.query)).toThrowError( + '[include_authorized_purposes]: expected value to equal [false]' ); } }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts index 2ee1146250b49..20ad5e730db6b 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts @@ -11,7 +11,7 @@ import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initGetAllSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, log, spacesService } = deps; + const { externalRouter, log, getSpacesService } = deps; externalRouter.get( { @@ -39,7 +39,7 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) { const { purpose, include_authorized_purposes: includeAuthorizedPurposes } = request.query; - const spacesClient = await spacesService.scopedClient(request); + const spacesClient = getSpacesService().createSpacesClient(request); let spaces: Space[]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts index f093f26b4bdee..e34f67adc04ac 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -5,13 +5,12 @@ */ import { Logger, IRouter, CoreSetup } from 'src/core/server'; -import { SecurityPluginSetup } from '../../../../../security/server'; import { initDeleteSpacesApi } from './delete'; import { initGetSpaceApi } from './get'; import { initGetAllSpacesApi } from './get_all'; import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; -import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; +import { SpacesServiceStart } from '../../../spaces_service/spaces_service'; import { initCopyToSpacesApi } from './copy_to_space'; import { initShareToSpacesApi } from './share_to_space'; @@ -19,9 +18,8 @@ export interface ExternalRouteDeps { externalRouter: IRouter; getStartServices: CoreSetup['getStartServices']; getImportExportObjectLimit: () => number; - spacesService: SpacesServiceSetup; + getSpacesService: () => SpacesServiceStart; log: Logger; - authorization: SecurityPluginSetup['authz'] | null; } export function initExternalSpacesApi(deps: ExternalRouteDeps) { diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index 6aeec251e33e4..bd8b4f2119109 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -10,7 +10,7 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingSystemMock, httpServerMock, @@ -18,12 +18,10 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { initPostSpacesApi } from './post'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; import { ObjectType } from '@kbn/config-schema'; +import { SpacesClientService } from '../../../spaces_client'; describe('Spaces Public API', () => { const spacesSavedObjects = createSpaces(); @@ -38,27 +36,21 @@ describe('Spaces Public API', () => { const log = loggingSystemMock.create().get('spaces'); - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, }); initPostSpacesApi({ @@ -66,8 +58,7 @@ describe('Spaces Public API', () => { getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization: null, // not needed for this route + getSpacesService: () => spacesServiceStart, }); const [routeDefinition, routeHandler] = router.post.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.ts b/x-pack/plugins/spaces/server/routes/api/external/post.ts index 0c77bcc74bb50..a6a1f26c7955c 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.ts @@ -11,7 +11,7 @@ import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initPostSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, log, spacesService } = deps; + const { externalRouter, log, getSpacesService } = deps; externalRouter.post( { @@ -22,7 +22,7 @@ export function initPostSpacesApi(deps: ExternalRouteDeps) { }, createLicensedRouteHandler(async (context, request, response) => { log.debug(`Inside POST /api/spaces/space`); - const spacesClient = await spacesService.scopedClient(request); + const spacesClient = getSpacesService().createSpacesClient(request); const space = request.body; diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index 326837f8995f0..d87cfd96e2429 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -11,7 +11,7 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingSystemMock, httpServiceMock, @@ -19,12 +19,10 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { initPutSpacesApi } from './put'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; import { ObjectType } from '@kbn/config-schema'; +import { SpacesClientService } from '../../../spaces_client'; describe('PUT /api/spaces/space', () => { const spacesSavedObjects = createSpaces(); @@ -39,27 +37,21 @@ describe('PUT /api/spaces/space', () => { const log = loggingSystemMock.create().get('spaces'); - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, }); initPutSpacesApi({ @@ -67,8 +59,7 @@ describe('PUT /api/spaces/space', () => { getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization: null, // not needed for this route + getSpacesService: () => spacesServiceStart, }); const [routeDefinition, routeHandler] = router.put.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.ts b/x-pack/plugins/spaces/server/routes/api/external/put.ts index 2054cf5d1c829..68ebdb55af1e3 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.ts @@ -13,7 +13,7 @@ import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initPutSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, spacesService } = deps; + const { externalRouter, getSpacesService } = deps; externalRouter.put( { @@ -26,7 +26,7 @@ export function initPutSpacesApi(deps: ExternalRouteDeps) { }, }, createLicensedRouteHandler(async (context, request, response) => { - const spacesClient = await spacesService.scopedClient(request); + const spacesClient = getSpacesService().createSpacesClient(request); const space = request.body; const id = request.params.id; diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts index 3af1d9d245d10..b376e56a87fd8 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts @@ -11,7 +11,7 @@ import { mockRouteContextWithInvalidLicense, createMockSavedObjectsService, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingSystemMock, httpServiceMock, @@ -19,21 +19,16 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { SpacesClient } from '../../../lib/spaces_client'; import { initShareToSpacesApi } from './share_to_space'; import { spacesConfig } from '../../../lib/__fixtures__'; -import { securityMock } from '../../../../../security/server/mocks'; import { ObjectType } from '@kbn/config-schema'; -import { SecurityPluginSetup } from '../../../../../security/server'; +import { SpacesClientService } from '../../../spaces_client'; describe('share to space', () => { const spacesSavedObjects = createSpaces(); const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); - const setup = async ({ - authorization = null, - }: { authorization?: SecurityPluginSetup['authz'] | null } = {}) => { + const setup = async () => { const httpService = httpServiceMock.createSetupContract(); const router = httpService.createRouter(); const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); @@ -42,36 +37,28 @@ describe('share to space', () => { const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces); coreStart.savedObjects = savedObjects; - const service = new SpacesService(log); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), - }); + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - savedObjectsRepositoryMock, - spacesConfig, - savedObjectsRepositoryMock, - req - ) - ); + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, + }); initShareToSpacesApi({ externalRouter: router, getStartServices: async () => [coreStart, {}, {}], getImportExportObjectLimit: () => 1000, log, - spacesService, - authorization, + getSpacesService: () => spacesServiceStart, }); const [ @@ -79,8 +66,6 @@ describe('share to space', () => { [shareRemove, resolveRouteHandler], ] = router.post.mock.calls; - const [[, permissionsRouteHandler]] = router.get.mock.calls; - return { coreStart, savedObjectsClient, @@ -92,76 +77,10 @@ describe('share to space', () => { routeValidation: shareRemove.validate as RouteValidatorConfig<{}, {}, {}>, routeHandler: resolveRouteHandler, }, - sharePermissions: { - routeHandler: permissionsRouteHandler, - }, savedObjectsRepositoryMock, }; }; - describe('GET /internal/spaces/_share_saved_object_permissions', () => { - it('returns true when security is not enabled', async () => { - const { sharePermissions } = await setup(); - - const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); - const response = await sharePermissions.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status, payload } = response; - expect(status).toEqual(200); - expect(payload).toEqual({ shareToAllSpaces: true }); - }); - - it('returns false when the user is not authorized globally', async () => { - const authorization = securityMock.createSetup().authz; - const globalPrivilegesCheck = jest.fn().mockResolvedValue({ hasAllRequested: false }); - authorization.checkPrivilegesWithRequest.mockReturnValue({ - globally: globalPrivilegesCheck, - }); - const { sharePermissions } = await setup({ authorization }); - - const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); - const response = await sharePermissions.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status, payload } = response; - expect(status).toEqual(200); - expect(payload).toEqual({ shareToAllSpaces: false }); - - expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); - expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - }); - - it('returns true when the user is authorized globally', async () => { - const authorization = securityMock.createSetup().authz; - const globalPrivilegesCheck = jest.fn().mockResolvedValue({ hasAllRequested: true }); - authorization.checkPrivilegesWithRequest.mockReturnValue({ - globally: globalPrivilegesCheck, - }); - const { sharePermissions } = await setup({ authorization }); - - const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); - const response = await sharePermissions.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status, payload } = response; - expect(status).toEqual(200); - expect(payload).toEqual({ shareToAllSpaces: true }); - - expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); - expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - }); - }); - describe('POST /api/spaces/_share_saved_object_add', () => { const object = { id: 'foo', type: 'bar' }; diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts index 7acf9e3e6e3d0..adb4708d52ab0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -13,7 +13,7 @@ import { createLicensedRouteHandler } from '../../lib'; const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); export function initShareToSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices, authorization } = deps; + const { externalRouter, getStartServices } = deps; const shareSchema = schema.object({ spaces: schema.arrayOf( @@ -37,31 +37,6 @@ export function initShareToSpacesApi(deps: ExternalRouteDeps) { object: schema.object({ type: schema.string(), id: schema.string() }), }); - externalRouter.get( - { - path: '/internal/spaces/_share_saved_object_permissions', - validate: { query: schema.object({ type: schema.string() }) }, - }, - createLicensedRouteHandler(async (_context, request, response) => { - let shareToAllSpaces = true; - const { type } = request.query; - - if (authorization) { - try { - const checkPrivileges = authorization.checkPrivilegesWithRequest(request); - shareToAllSpaces = ( - await checkPrivileges.globally({ - kibana: authorization.actions.savedObject.get(type, 'share_to_space'), - }) - ).hasAllRequested; - } catch (error) { - return response.customError(wrapError(error)); - } - } - return response.ok({ body: { shareToAllSpaces } }); - }) - ); - externalRouter.post( { path: '/api/spaces/_share_saved_object_add', validate: { body: shareSchema } }, createLicensedRouteHandler(async (_context, request, response) => { diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts index 086d5f5bc94bb..4f1d8fa912572 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts @@ -3,14 +3,12 @@ * 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 { mockRouteContextWithInvalidLicense } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; import { httpServiceMock, httpServerMock, coreMock } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { spacesConfig } from '../../../lib/__fixtures__'; import { initGetActiveSpaceApi } from './get_active_space'; +import { spacesClientServiceMock } from '../../../spaces_client/spaces_client_service.mock'; describe('GET /internal/spaces/_active_space', () => { const setup = async () => { @@ -19,18 +17,18 @@ describe('GET /internal/spaces/_active_space', () => { const coreStart = coreMock.createStart(); - const service = new SpacesService(null as any); - const spacesService = await service.setup({ - http: (httpService as unknown) as CoreSetup['http'], - getStartServices: async () => [coreStart, {}, {}], - authorization: null, - auditLogger: {} as SpacesAuditLogger, - config$: Rx.of(spacesConfig), + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, }); initGetActiveSpaceApi({ internalRouter: router, - spacesService, + getSpacesService: () => + service.start({ + basePath: coreStart.http.basePath, + spacesClientService: spacesClientServiceMock.createStart(), + }), }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts index fa9dafa526da8..9a73704e2ea77 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts @@ -9,7 +9,7 @@ import { InternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initGetActiveSpaceApi(deps: InternalRouteDeps) { - const { internalRouter, spacesService } = deps; + const { internalRouter, getSpacesService } = deps; internalRouter.get( { @@ -18,7 +18,7 @@ export function initGetActiveSpaceApi(deps: InternalRouteDeps) { }, createLicensedRouteHandler(async (context, request, response) => { try { - const space = await spacesService.getActiveSpace(request); + const space = await getSpacesService().getActiveSpace(request); return response.ok({ body: space }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/spaces/server/routes/api/internal/index.ts b/x-pack/plugins/spaces/server/routes/api/internal/index.ts index 12ce50f228bfc..675cdb548543d 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/index.ts @@ -5,12 +5,12 @@ */ import { IRouter } from 'src/core/server'; -import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; +import { SpacesServiceStart } from '../../../spaces_service/spaces_service'; import { initGetActiveSpaceApi } from './get_active_space'; export interface InternalRouteDeps { internalRouter: IRouter; - spacesService: SpacesServiceSetup; + getSpacesService: () => SpacesServiceStart; } export function initInternalSpacesApi(deps: InternalRouteDeps) { diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts index e545cccfeadd7..7e19deae0092e 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts @@ -9,16 +9,16 @@ import { SavedObjectsClientWrapperOptions, } from 'src/core/server'; import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; -import { SpacesServiceSetup } from '../spaces_service/spaces_service'; +import { SpacesServiceStart } from '../spaces_service/spaces_service'; export function spacesSavedObjectsClientWrapperFactory( - spacesService: SpacesServiceSetup + getSpacesService: () => SpacesServiceStart ): SavedObjectsClientWrapperFactory { return (options: SavedObjectsClientWrapperOptions) => new SpacesSavedObjectsClient({ baseClient: options.client, request: options.request, - spacesService, + getSpacesService, typeRegistry: options.typeRegistry, }); } diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts index 31f2c98d74c96..a0b0ab41e9d89 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts @@ -12,10 +12,10 @@ describe('SpacesSavedObjectsService', () => { describe('#setup', () => { it('registers the "space" saved object type with appropriate mappings and migrations', () => { const core = coreMock.createSetup(); - const spacesService = spacesServiceMock.createSetupContract(); + const spacesService = spacesServiceMock.createStartContract(); const service = new SpacesSavedObjectsService(); - service.setup({ core, spacesService }); + service.setup({ core, getSpacesService: () => spacesService }); expect(core.savedObjects.registerType).toHaveBeenCalledTimes(1); expect(core.savedObjects.registerType.mock.calls[0]).toMatchInlineSnapshot(` @@ -66,10 +66,10 @@ describe('SpacesSavedObjectsService', () => { it('registers the client wrapper', () => { const core = coreMock.createSetup(); - const spacesService = spacesServiceMock.createSetupContract(); + const spacesService = spacesServiceMock.createStartContract(); const service = new SpacesSavedObjectsService(); - service.setup({ core, spacesService }); + service.setup({ core, getSpacesService: () => spacesService }); expect(core.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1); expect(core.savedObjects.addClientWrapper).toHaveBeenCalledWith( diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts index 58aa1fe08558a..b52f1eda1b6ac 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts @@ -8,15 +8,15 @@ import { CoreSetup } from 'src/core/server'; import { SpacesSavedObjectMappings } from './mappings'; import { migrateToKibana660 } from './migrations'; import { spacesSavedObjectsClientWrapperFactory } from './saved_objects_client_wrapper_factory'; -import { SpacesServiceSetup } from '../spaces_service'; +import { SpacesServiceStart } from '../spaces_service'; interface SetupDeps { core: Pick; - spacesService: SpacesServiceSetup; + getSpacesService: () => SpacesServiceStart; } export class SpacesSavedObjectsService { - public setup({ core, spacesService }: SetupDeps) { + public setup({ core, getSpacesService }: SetupDeps) { core.savedObjects.registerType({ name: 'space', hidden: true, @@ -30,7 +30,7 @@ export class SpacesSavedObjectsService { core.savedObjects.addClientWrapper( Number.MIN_SAFE_INTEGER, 'spaces', - spacesSavedObjectsClientWrapperFactory(spacesService) + spacesSavedObjectsClientWrapperFactory(getSpacesService) ); } } diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 65413a5b5042f..88adf98248d2c 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -9,8 +9,8 @@ import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { SavedObjectTypeRegistry } from 'src/core/server'; -import { SpacesClient } from '../lib/spaces_client'; -import { spacesClientMock } from '../lib/spaces_client/spaces_client.mock'; +import { SpacesClient } from '../spaces_client'; +import { spacesClientMock } from '../spaces_client/spaces_client.mock'; import Boom from '@hapi/boom'; const typeRegistry = new SavedObjectTypeRegistry(); @@ -39,8 +39,8 @@ const createMockRequest = () => ({}); const createMockClient = () => savedObjectsClientMock.create(); -const createSpacesService = async (spaceId: string) => { - return spacesServiceMock.createSetupContract(spaceId); +const createSpacesService = (spaceId: string) => { + return spacesServiceMock.createStartContract(spaceId); }; const createMockResponse = () => ({ @@ -61,15 +61,15 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; { id: 'space_1', expectedNamespace: 'space_1' }, ].forEach((currentSpace) => { describe(`${currentSpace.id} space`, () => { - const createSpacesSavedObjectsClient = async () => { + const createSpacesSavedObjectsClient = () => { const request = createMockRequest(); const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); + const spacesService = createSpacesService(currentSpace.id); const client = new SpacesSavedObjectsClient({ request, baseClient, - spacesService, + getSpacesService: () => spacesService, typeRegistry, }); return { client, baseClient, spacesService }; @@ -77,7 +77,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#get', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect(client.get('foo', '', { namespace: 'bar' })).rejects.toThrow( ERROR_NAMESPACE_SPECIFIED @@ -85,7 +85,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.get.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -105,7 +105,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#bulkGet', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }) @@ -113,7 +113,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkGet.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -134,10 +134,10 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const EMPTY_RESPONSE = { saved_objects: [], total: 0, per_page: 20, page: 1 }; test(`returns empty result if user is unauthorized in this space`, async () => { - const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const { client, baseClient, spacesService } = createSpacesSavedObjectsClient(); const spacesClient = spacesClientMock.create(); spacesClient.getAll.mockResolvedValue([]); - spacesService.scopedClient.mockResolvedValue(spacesClient); + spacesService.createSpacesClient.mockReturnValue(spacesClient); const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] }); const actualReturnValue = await client.find(options); @@ -147,10 +147,10 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`returns empty result if user is unauthorized in any space`, async () => { - const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const { client, baseClient, spacesService } = createSpacesSavedObjectsClient(); const spacesClient = spacesClientMock.create(); spacesClient.getAll.mockRejectedValue(Boom.unauthorized()); - spacesService.scopedClient.mockResolvedValue(spacesClient); + spacesService.createSpacesClient.mockReturnValue(spacesClient); const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] }); const actualReturnValue = await client.find(options); @@ -160,7 +160,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`passes options.type to baseClient if valid singular type specified`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()].map((obj) => ({ ...obj, score: 1 })), total: 1, @@ -180,7 +180,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()].map((obj) => ({ ...obj, score: 1 })), total: 1, @@ -200,7 +200,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`passes options.namespaces along`, async () => { - const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const { client, baseClient, spacesService } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -209,7 +209,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked< + const spacesClient = spacesService.createSpacesClient(null as any) as jest.Mocked< SpacesClient >; spacesClient.getAll.mockImplementation(() => @@ -231,7 +231,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`filters options.namespaces based on authorization`, async () => { - const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const { client, baseClient, spacesService } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -240,7 +240,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked< + const spacesClient = spacesService.createSpacesClient(null as any) as jest.Mocked< SpacesClient >; spacesClient.getAll.mockImplementation(() => @@ -262,7 +262,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`translates options.namespace: ['*']`, async () => { - const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const { client, baseClient, spacesService } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -271,7 +271,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked< + const spacesClient = spacesService.createSpacesClient(null as any) as jest.Mocked< SpacesClient >; spacesClient.getAll.mockImplementation(() => @@ -295,7 +295,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#checkConflicts', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -304,7 +304,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { errors: [] }; baseClient.checkConflicts.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -323,7 +323,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#create', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect(client.create('foo', {}, { namespace: 'bar' })).rejects.toThrow( ERROR_NAMESPACE_SPECIFIED @@ -331,7 +331,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.create.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -351,7 +351,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#bulkCreate', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { namespace: 'bar' }) @@ -359,7 +359,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkCreate.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -378,7 +378,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#update', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -387,7 +387,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.update.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -408,7 +408,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#bulkUpdate', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -417,7 +417,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkUpdate.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -442,7 +442,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#delete', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -451,7 +451,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.delete.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -471,7 +471,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#addToNamespaces', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -480,7 +480,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { namespaces: ['foo', 'bar'] }; baseClient.addToNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -501,7 +501,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#deleteFromNamespaces', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -510,7 +510,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { namespaces: ['foo', 'bar'] }; baseClient.deleteFromNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); @@ -531,7 +531,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; describe('#removeReferencesTo', () => { test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); + const { client } = createSpacesSavedObjectsClient(); await expect( // @ts-expect-error @@ -540,7 +540,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = await createSpacesSavedObjectsClient(); + const { client, baseClient } = createSpacesSavedObjectsClient(); const expectedReturnValue = { updated: 12 }; baseClient.removeReferencesTo.mockReturnValue(Promise.resolve(expectedReturnValue)); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 183aea26edab7..049bd88085ed5 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -22,14 +22,14 @@ import { ISavedObjectTypeRegistry, } from '../../../../../src/core/server'; import { ALL_SPACES_ID } from '../../common/constants'; -import { SpacesServiceSetup } from '../spaces_service/spaces_service'; +import { SpacesServiceStart } from '../spaces_service/spaces_service'; import { spaceIdToNamespace } from '../lib/utils/namespace'; -import { SpacesClient } from '../lib/spaces_client'; +import { ISpacesClient } from '../spaces_client'; interface SpacesSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; request: any; - spacesService: SpacesServiceSetup; + getSpacesService: () => SpacesServiceStart; typeRegistry: ISavedObjectTypeRegistry; } @@ -51,14 +51,16 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { private readonly client: SavedObjectsClientContract; private readonly spaceId: string; private readonly types: string[]; - private readonly getSpacesClient: Promise; + private readonly spacesClient: ISpacesClient; public readonly errors: SavedObjectsClientContract['errors']; constructor(options: SpacesSavedObjectsClientOptions) { - const { baseClient, request, spacesService, typeRegistry } = options; + const { baseClient, request, getSpacesService, typeRegistry } = options; + + const spacesService = getSpacesService(); this.client = baseClient; - this.getSpacesClient = spacesService.scopedClient(request); + this.spacesClient = spacesService.createSpacesClient(request); this.spaceId = spacesService.getSpaceId(request); this.types = typeRegistry.getAllTypes().map((t) => t.name); this.errors = baseClient.errors; @@ -167,10 +169,8 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { let namespaces = options.namespaces; if (namespaces) { - const spacesClient = await this.getSpacesClient; - try { - const availableSpaces = await spacesClient.getAll({ purpose: 'findSavedObjects' }); + const availableSpaces = await this.spacesClient.getAll({ purpose: 'findSavedObjects' }); if (namespaces.includes(ALL_SPACES_ID)) { namespaces = availableSpaces.map((space) => space.id); } else { diff --git a/x-pack/plugins/spaces/server/spaces_client/index.ts b/x-pack/plugins/spaces/server/spaces_client/index.ts new file mode 100644 index 0000000000000..05c9dbd3fdb95 --- /dev/null +++ b/x-pack/plugins/spaces/server/spaces_client/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { SpacesClient, ISpacesClient } from './spaces_client'; +export { + SpacesClientService, + SpacesClientServiceSetup, + SpacesClientServiceStart, + SpacesClientRepositoryFactory, + SpacesClientWrapper, +} from './spaces_client_service'; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts similarity index 90% rename from x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts rename to x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts index e38842b8799ac..8383d32cc6517 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { Space } from '../../../common/model/space'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { Space } from '../../common/model/space'; import { SpacesClient } from './spaces_client'; const createSpacesClientMock = () => diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts new file mode 100644 index 0000000000000..7c2f90f5dfb2c --- /dev/null +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts @@ -0,0 +1,341 @@ +/* + * 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 { SpacesClient } from './spaces_client'; +import { ConfigType, ConfigSchema } from '../config'; +import { GetAllSpacesPurpose } from '../../common/model/types'; +import { savedObjectsRepositoryMock } from '../../../../../src/core/server/mocks'; + +const createMockDebugLogger = () => { + return jest.fn(); +}; + +const createMockConfig = (mockConfig: ConfigType = { maxSpaces: 1000, enabled: true }) => { + return ConfigSchema.validate(mockConfig); +}; + +describe('#getAll', () => { + const savedObjects = [ + { + id: 'foo', + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }, + { + id: 'bar', + attributes: { + name: 'bar-name', + description: 'bar-description', + bar: 'bar-bar', + }, + }, + { + id: 'baz', + attributes: { + name: 'baz-name', + description: 'baz-description', + bar: 'baz-bar', + }, + }, + ]; + + const expectedSpaces = [ + { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + { + id: 'bar', + name: 'bar-name', + description: 'bar-description', + bar: 'bar-bar', + }, + { + id: 'baz', + name: 'baz-name', + description: 'baz-description', + bar: 'baz-bar', + }, + ]; + + test(`finds spaces using callWithRequestRepository`, async () => { + const mockDebugLogger = createMockDebugLogger(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.find.mockResolvedValue({ + saved_objects: savedObjects, + } as any); + const mockConfig = createMockConfig({ + maxSpaces: 1234, + enabled: true, + }); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const actualSpaces = await client.getAll(); + + expect(actualSpaces).toEqual(expectedSpaces); + expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: mockConfig.maxSpaces, + sortField: 'name.keyword', + }); + }); + + test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => { + const client = new SpacesClient(null as any, null as any, null as any); + await expect( + client.getAll({ purpose: 'invalid_purpose' as GetAllSpacesPurpose }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"unsupported space purpose: invalid_purpose"`); + }); +}); + +describe('#get', () => { + const savedObject = { + id: 'foo', + type: 'foo', + references: [], + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }; + + const expectedSpace = { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }; + + test(`gets space using callWithRequestRepository`, async () => { + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.get.mockResolvedValue(savedObject); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const id = savedObject.id; + const actualSpace = await client.get(id); + + expect(actualSpace).toEqual(expectedSpace); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + }); +}); + +describe('#create', () => { + const id = 'foo'; + + const spaceToCreate = { + id, + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + disabledFeatures: [], + }; + + const attributes = { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + disabledFeatures: [], + }; + + const savedObject = { + id, + type: 'foo', + references: [], + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + disabledFeatures: [], + }, + }; + + const expectedReturnedSpace = { + id, + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + disabledFeatures: [], + }; + + test(`creates space using callWithRequestRepository when we're under the max`, async () => { + const maxSpaces = 5; + const mockDebugLogger = createMockDebugLogger(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.create.mockResolvedValue(savedObject); + mockCallWithRequestRepository.find.mockResolvedValue({ + total: maxSpaces - 1, + } as any); + + const mockConfig = createMockConfig({ + maxSpaces, + enabled: true, + }); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + + const actualSpace = await client.create(spaceToCreate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: 0, + }); + expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith('space', attributes, { + id, + }); + }); + + test(`throws bad request when we are at the maximum number of spaces`, async () => { + const maxSpaces = 5; + const mockDebugLogger = createMockDebugLogger(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.create.mockResolvedValue(savedObject); + mockCallWithRequestRepository.find.mockResolvedValue({ + total: maxSpaces, + } as any); + + const mockConfig = createMockConfig({ + maxSpaces, + enabled: true, + }); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + + expect(client.create(spaceToCreate)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"` + ); + + expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: 0, + }); + expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled(); + }); +}); + +describe('#update', () => { + const spaceToUpdate = { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: false, + disabledFeatures: [], + }; + + const attributes = { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + disabledFeatures: [], + }; + + const savedObject = { + id: 'foo', + type: 'foo', + references: [], + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + disabledFeatures: [], + }, + }; + + const expectedReturnedSpace = { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + disabledFeatures: [], + }; + + test(`updates space using callWithRequestRepository`, async () => { + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.get.mockResolvedValue(savedObject); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const id = savedObject.id; + const actualSpace = await client.update(id, spaceToUpdate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, attributes); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + }); +}); + +describe('#delete', () => { + const id = 'foo'; + + const reservedSavedObject = { + id, + type: 'foo', + references: [], + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + }, + }; + + const notReservedSavedObject = { + id, + type: 'foo', + references: [], + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }; + + test(`throws bad request when the space is reserved`, async () => { + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + + expect(client.delete(id)).rejects.toThrowErrorMatchingInlineSnapshot( + `"The foo space cannot be deleted because it is reserved."` + ); + + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + }); + + test(`deletes space using callWithRequestRepository when space isn't reserved`, async () => { + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + + await client.delete(id); + + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id); + expect(mockCallWithRequestRepository.deleteByNamespace).toHaveBeenCalledWith(id); + }); +}); diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts new file mode 100644 index 0000000000000..7142ec8dc2fba --- /dev/null +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts @@ -0,0 +1,110 @@ +/* + * 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 '@hapi/boom'; +import { omit } from 'lodash'; +import { ISavedObjectsRepository, SavedObject } from 'src/core/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { isReservedSpace } from '../../common'; +import { Space } from '../../common/model/space'; +import { ConfigType } from '../config'; +import { GetAllSpacesPurpose, GetSpaceResult } from '../../common/model/types'; + +export interface GetAllSpacesOptions { + purpose?: GetAllSpacesPurpose; + includeAuthorizedPurposes?: boolean; +} + +const SUPPORTED_GET_SPACE_PURPOSES: GetAllSpacesPurpose[] = [ + 'any', + 'copySavedObjectsIntoSpace', + 'findSavedObjects', + 'shareSavedObjectsIntoSpace', +]; +const DEFAULT_PURPOSE = 'any'; + +export type ISpacesClient = PublicMethodsOf; + +export class SpacesClient { + constructor( + private readonly debugLogger: (message: string) => void, + private readonly config: ConfigType, + private readonly repository: ISavedObjectsRepository + ) {} + + public async getAll(options: GetAllSpacesOptions = {}): Promise { + const { purpose = DEFAULT_PURPOSE } = options; + if (!SUPPORTED_GET_SPACE_PURPOSES.includes(purpose)) { + throw Boom.badRequest(`unsupported space purpose: ${purpose}`); + } + + this.debugLogger(`SpacesClient.getAll(). querying all spaces`); + + const { saved_objects: savedObjects } = await this.repository.find({ + type: 'space', + page: 1, + perPage: this.config.maxSpaces, + sortField: 'name.keyword', + }); + + this.debugLogger(`SpacesClient.getAll(). Found ${savedObjects.length} spaces.`); + + return savedObjects.map(this.transformSavedObjectToSpace); + } + + public async get(id: string) { + const savedObject = await this.repository.get('space', id); + return this.transformSavedObjectToSpace(savedObject); + } + + public async create(space: Space) { + const { total } = await this.repository.find({ + type: 'space', + page: 1, + perPage: 0, + }); + if (total >= this.config.maxSpaces) { + throw Boom.badRequest( + 'Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting' + ); + } + + this.debugLogger(`SpacesClient.create(), using RBAC. Attempting to create space`); + + const attributes = omit(space, ['id', '_reserved']); + const id = space.id; + const createdSavedObject = await this.repository.create('space', attributes, { id }); + + this.debugLogger(`SpacesClient.create(), created space object`); + + return this.transformSavedObjectToSpace(createdSavedObject); + } + + public async update(id: string, space: Space) { + const attributes = omit(space, 'id', '_reserved'); + await this.repository.update('space', id, attributes); + const updatedSavedObject = await this.repository.get('space', id); + return this.transformSavedObjectToSpace(updatedSavedObject); + } + + public async delete(id: string) { + const existingSavedObject = await this.repository.get('space', id); + if (isReservedSpace(this.transformSavedObjectToSpace(existingSavedObject))) { + throw Boom.badRequest(`The ${id} space cannot be deleted because it is reserved.`); + } + + await this.repository.deleteByNamespace(id); + + await this.repository.delete('space', id); + } + + private transformSavedObjectToSpace(savedObject: SavedObject) { + return { + id: savedObject.id, + ...savedObject.attributes, + } as Space; + } +} diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.mock.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.mock.ts new file mode 100644 index 0000000000000..d80fadd7652c2 --- /dev/null +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { spacesClientMock } from '../mocks'; + +import { SpacesClientServiceSetup, SpacesClientServiceStart } from './spaces_client_service'; + +const createSpacesClientServiceSetupMock = () => + ({ + registerClientWrapper: jest.fn(), + setClientRepositoryFactory: jest.fn(), + } as jest.Mocked); + +const createSpacesClientServiceStartMock = () => + ({ + createSpacesClient: jest.fn().mockReturnValue(spacesClientMock.create()), + } as jest.Mocked); + +export const spacesClientServiceMock = { + createSetup: createSpacesClientServiceSetupMock, + createStart: createSpacesClientServiceStartMock, +}; diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.test.ts new file mode 100644 index 0000000000000..77733a4d7d472 --- /dev/null +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { coreMock, httpServerMock } from 'src/core/server/mocks'; +import { ConfigType } from '../config'; +import { spacesConfig } from '../lib/__fixtures__'; +import { ISpacesClient, SpacesClient } from './spaces_client'; +import { SpacesClientService } from './spaces_client_service'; + +const debugLogger = jest.fn(); + +describe('SpacesClientService', () => { + describe('#setup', () => { + it('allows a single repository factory to be set', () => { + const service = new SpacesClientService(debugLogger); + const setup = service.setup({ config$: Rx.of(spacesConfig) }); + + const repositoryFactory = jest.fn(); + setup.setClientRepositoryFactory(repositoryFactory); + + expect(() => + setup.setClientRepositoryFactory(repositoryFactory) + ).toThrowErrorMatchingInlineSnapshot(`"Repository factory has already been set"`); + }); + + it('allows a single client wrapper to be set', () => { + const service = new SpacesClientService(debugLogger); + const setup = service.setup({ config$: Rx.of(spacesConfig) }); + + const clientWrapper = jest.fn(); + setup.registerClientWrapper(clientWrapper); + + expect(() => setup.registerClientWrapper(clientWrapper)).toThrowErrorMatchingInlineSnapshot( + `"Client wrapper has already been set"` + ); + }); + }); + + describe('#start', () => { + it('throws if config is not available', () => { + const service = new SpacesClientService(debugLogger); + service.setup({ config$: new Rx.Observable() }); + const coreStart = coreMock.createStart(); + const start = service.start(coreStart); + + const request = httpServerMock.createKibanaRequest(); + + expect(() => start.createSpacesClient(request)).toThrowErrorMatchingInlineSnapshot( + `"Initialization error: spaces config is not available"` + ); + }); + + describe('without a custom repository factory or wrapper', () => { + it('returns an instance of the spaces client using the scoped repository', () => { + const service = new SpacesClientService(debugLogger); + service.setup({ config$: Rx.of(spacesConfig) }); + + const coreStart = coreMock.createStart(); + const start = service.start(coreStart); + + const request = httpServerMock.createKibanaRequest(); + const client = start.createSpacesClient(request); + expect(client).toBeInstanceOf(SpacesClient); + + expect(coreStart.savedObjects.createScopedRepository).toHaveBeenCalledWith(request, [ + 'space', + ]); + expect(coreStart.savedObjects.createInternalRepository).not.toHaveBeenCalled(); + }); + }); + + it('uses the custom repository factory when set', () => { + const service = new SpacesClientService(debugLogger); + const setup = service.setup({ config$: Rx.of(spacesConfig) }); + + const customRepositoryFactory = jest.fn(); + setup.setClientRepositoryFactory(customRepositoryFactory); + + const coreStart = coreMock.createStart(); + const start = service.start(coreStart); + + const request = httpServerMock.createKibanaRequest(); + const client = start.createSpacesClient(request); + expect(client).toBeInstanceOf(SpacesClient); + + expect(coreStart.savedObjects.createScopedRepository).not.toHaveBeenCalled(); + expect(coreStart.savedObjects.createInternalRepository).not.toHaveBeenCalled(); + + expect(customRepositoryFactory).toHaveBeenCalledWith(request, coreStart.savedObjects); + }); + + it('wraps the client in the wrapper when registered', () => { + const service = new SpacesClientService(debugLogger); + const setup = service.setup({ config$: Rx.of(spacesConfig) }); + + const wrapper = (Symbol() as unknown) as ISpacesClient; + + const clientWrapper = jest.fn().mockReturnValue(wrapper); + setup.registerClientWrapper(clientWrapper); + + const coreStart = coreMock.createStart(); + const start = service.start(coreStart); + + const request = httpServerMock.createKibanaRequest(); + const client = start.createSpacesClient(request); + + expect(client).toBe(wrapper); + expect(clientWrapper).toHaveBeenCalledTimes(1); + expect(clientWrapper).toHaveBeenCalledWith(request, expect.any(SpacesClient)); + + expect(coreStart.savedObjects.createScopedRepository).toHaveBeenCalledWith(request, [ + 'space', + ]); + expect(coreStart.savedObjects.createInternalRepository).not.toHaveBeenCalled(); + }); + + it('wraps the client in the wrapper when registered, using the custom repository factory when configured', () => { + const service = new SpacesClientService(debugLogger); + const setup = service.setup({ config$: Rx.of(spacesConfig) }); + + const customRepositoryFactory = jest.fn(); + setup.setClientRepositoryFactory(customRepositoryFactory); + + const wrapper = (Symbol() as unknown) as ISpacesClient; + + const clientWrapper = jest.fn().mockReturnValue(wrapper); + setup.registerClientWrapper(clientWrapper); + + const coreStart = coreMock.createStart(); + const start = service.start(coreStart); + + const request = httpServerMock.createKibanaRequest(); + const client = start.createSpacesClient(request); + + expect(client).toBe(wrapper); + expect(clientWrapper).toHaveBeenCalledTimes(1); + expect(clientWrapper).toHaveBeenCalledWith(request, expect.any(SpacesClient)); + + expect(coreStart.savedObjects.createScopedRepository).not.toHaveBeenCalled(); + expect(coreStart.savedObjects.createInternalRepository).not.toHaveBeenCalled(); + + expect(customRepositoryFactory).toHaveBeenCalledWith(request, coreStart.savedObjects); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts new file mode 100644 index 0000000000000..d2a25c28cf192 --- /dev/null +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts @@ -0,0 +1,109 @@ +/* + * 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 { Observable } from 'rxjs'; +import { + KibanaRequest, + CoreStart, + ISavedObjectsRepository, + SavedObjectsServiceStart, +} from 'src/core/server'; +import { ConfigType } from '../config'; +import { SpacesClient, ISpacesClient } from './spaces_client'; + +export type SpacesClientWrapper = ( + request: KibanaRequest, + baseClient: ISpacesClient +) => ISpacesClient; + +export type SpacesClientRepositoryFactory = ( + request: KibanaRequest, + savedObjectsStart: SavedObjectsServiceStart +) => ISavedObjectsRepository; + +export interface SpacesClientServiceSetup { + /** + * Sets the factory that should be used to create the Saved Objects Repository + * whenever a new instance of the SpacesClient is created. By default, a repository + * scoped to the current user will be created. + */ + setClientRepositoryFactory: (factory: SpacesClientRepositoryFactory) => void; + + /** + * Sets the client wrapper that should be used to optionally "wrap" each instance of the SpacesClient. + * By default, an unwrapped client will be created. + * + * Unlike the SavedObjectsClientWrappers, this service only supports a single wrapper. It is not possible + * to register multiple wrappers at this time. + */ + registerClientWrapper: (wrapper: SpacesClientWrapper) => void; +} + +export interface SpacesClientServiceStart { + /** + * Creates an instance of the SpacesClient scoped to the provided request. + */ + createSpacesClient: (request: KibanaRequest) => ISpacesClient; +} + +interface SetupDeps { + config$: Observable; +} + +export class SpacesClientService { + private repositoryFactory?: SpacesClientRepositoryFactory; + + private config?: ConfigType; + + private clientWrapper?: SpacesClientWrapper; + + constructor(private readonly debugLogger: (message: string) => void) {} + + public setup({ config$ }: SetupDeps): SpacesClientServiceSetup { + config$.subscribe((nextConfig) => { + this.config = nextConfig; + }); + + return { + setClientRepositoryFactory: (repositoryFactory: SpacesClientRepositoryFactory) => { + if (this.repositoryFactory) { + throw new Error(`Repository factory has already been set`); + } + this.repositoryFactory = repositoryFactory; + }, + registerClientWrapper: (wrapper: SpacesClientWrapper) => { + if (this.clientWrapper) { + throw new Error(`Client wrapper has already been set`); + } + this.clientWrapper = wrapper; + }, + }; + } + + public start(coreStart: CoreStart): SpacesClientServiceStart { + if (!this.repositoryFactory) { + this.repositoryFactory = (request, savedObjectsStart) => + savedObjectsStart.createScopedRepository(request, ['space']); + } + return { + createSpacesClient: (request: KibanaRequest) => { + if (!this.config) { + throw new Error('Initialization error: spaces config is not available'); + } + + const baseClient = new SpacesClient( + this.debugLogger, + this.config, + this.repositoryFactory!(request, coreStart.savedObjects) + ); + if (this.clientWrapper) { + return this.clientWrapper(request, baseClient); + } + return baseClient; + }, + }; + } +} diff --git a/x-pack/plugins/spaces/server/spaces_service/index.ts b/x-pack/plugins/spaces/server/spaces_service/index.ts index 69a7e171a5186..ee3f1505ebaad 100644 --- a/x-pack/plugins/spaces/server/spaces_service/index.ts +++ b/x-pack/plugins/spaces/server/spaces_service/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SpacesService, SpacesServiceSetup } from './spaces_service'; +export { SpacesService, SpacesServiceSetup, SpacesServiceStart } from './spaces_service'; diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts index 6f21330368f8d..18a2f20a4ee14 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts @@ -4,24 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesServiceSetup } from './spaces_service'; -import { spacesClientMock } from '../lib/spaces_client/spaces_client.mock'; +import { SpacesServiceSetup, SpacesServiceStart } from './spaces_service'; +import { spacesClientMock } from '../spaces_client/spaces_client.mock'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import { namespaceToSpaceId, spaceIdToNamespace } from '../lib/utils/namespace'; const createSetupContractMock = (spaceId = DEFAULT_SPACE_ID) => { const setupContract: jest.Mocked = { + namespaceToSpaceId: jest.fn().mockImplementation(namespaceToSpaceId), + spaceIdToNamespace: jest.fn().mockImplementation(spaceIdToNamespace), getSpaceId: jest.fn().mockReturnValue(spaceId), - isInDefaultSpace: jest.fn().mockReturnValue(spaceId === DEFAULT_SPACE_ID), - getBasePath: jest.fn().mockReturnValue(''), - scopedClient: jest.fn().mockResolvedValue(spacesClientMock.create()), + }; + return setupContract; +}; + +const createStartContractMock = (spaceId = DEFAULT_SPACE_ID) => { + const startContract: jest.Mocked = { namespaceToSpaceId: jest.fn().mockImplementation(namespaceToSpaceId), spaceIdToNamespace: jest.fn().mockImplementation(spaceIdToNamespace), + createSpacesClient: jest.fn().mockReturnValue(spacesClientMock.create()), + getSpaceId: jest.fn().mockReturnValue(spaceId), + isInDefaultSpace: jest.fn().mockReturnValue(spaceId === DEFAULT_SPACE_ID), getActiveSpace: jest.fn(), }; - return setupContract; + return startContract; }; export const spacesServiceMock = { createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, }; diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index d1e1d81134940..c7a65ec807b60 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -5,8 +5,7 @@ */ import * as Rx from 'rxjs'; import { SpacesService } from './spaces_service'; -import { coreMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; -import { SpacesAuditLogger } from '../lib/audit_logger'; +import { coreMock, httpServerMock } from 'src/core/server/mocks'; import { KibanaRequest, SavedObjectsErrorHelpers, @@ -16,12 +15,10 @@ import { import { DEFAULT_SPACE_ID } from '../../common/constants'; import { getSpaceIdFromPath } from '../../common/lib/spaces_url_parser'; import { spacesConfig } from '../lib/__fixtures__'; -import { securityMock } from '../../../security/server/mocks'; +import { SpacesClientService } from '../spaces_client'; -const mockLogger = loggingSystemMock.createLogger(); - -const createService = async (serverBasePath: string = '') => { - const spacesService = new SpacesService(mockLogger); +const createService = (serverBasePath: string = '') => { + const spacesService = new SpacesService(); const coreStart = coreMock.createStart(); @@ -66,117 +63,95 @@ const createService = async (serverBasePath: string = '') => { return '/'; }); - const spacesServiceSetup = await spacesService.setup({ - http: httpSetup, - getStartServices: async () => [coreStart, {}, {}], + coreStart.http.basePath = httpSetup.basePath; + + const spacesServiceSetup = spacesService.setup({ + basePath: httpSetup.basePath, + }); + + const spacesClientService = new SpacesClientService(jest.fn()); + spacesClientService.setup({ config$: Rx.of(spacesConfig), - authorization: securityMock.createSetup().authz, - auditLogger: new SpacesAuditLogger(), }); - return spacesServiceSetup; + const spacesClientServiceStart = spacesClientService.start(coreStart); + + const spacesServiceStart = spacesService.start({ + basePath: coreStart.http.basePath, + spacesClientService: spacesClientServiceStart, + }); + + return { + spacesServiceSetup, + spacesServiceStart, + }; }; describe('SpacesService', () => { describe('#getSpaceId', () => { it('returns the default space id when no identifier is present', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request: KibanaRequest = { url: { pathname: '/app/kibana' }, } as KibanaRequest; - expect(spacesServiceSetup.getSpaceId(request)).toEqual(DEFAULT_SPACE_ID); + expect(spacesServiceStart.getSpaceId(request)).toEqual(DEFAULT_SPACE_ID); }); it('returns the space id when identifier is present', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request: KibanaRequest = { url: { pathname: '/s/foo/app/kibana' }, } as KibanaRequest; - expect(spacesServiceSetup.getSpaceId(request)).toEqual('foo'); - }); - }); - - describe('#getBasePath', () => { - it(`throws when a space id is not provided`, async () => { - const spacesServiceSetup = await createService(); - - // @ts-ignore TS knows this isn't right - expect(() => spacesServiceSetup.getBasePath()).toThrowErrorMatchingInlineSnapshot( - `"spaceId is required to retrieve base path"` - ); - - expect(() => spacesServiceSetup.getBasePath('')).toThrowErrorMatchingInlineSnapshot( - `"spaceId is required to retrieve base path"` - ); - }); - - it('returns "" for the default space and no server base path', async () => { - const spacesServiceSetup = await createService(); - expect(spacesServiceSetup.getBasePath(DEFAULT_SPACE_ID)).toEqual(''); - }); - - it('returns /sbp for the default space and the "/sbp" server base path', async () => { - const spacesServiceSetup = await createService('/sbp'); - expect(spacesServiceSetup.getBasePath(DEFAULT_SPACE_ID)).toEqual('/sbp'); - }); - - it('returns /s/foo for the foo space and no server base path', async () => { - const spacesServiceSetup = await createService(); - expect(spacesServiceSetup.getBasePath('foo')).toEqual('/s/foo'); - }); - - it('returns /sbp/s/foo for the foo space and the "/sbp" server base path', async () => { - const spacesServiceSetup = await createService('/sbp'); - expect(spacesServiceSetup.getBasePath('foo')).toEqual('/sbp/s/foo'); + expect(spacesServiceStart.getSpaceId(request)).toEqual('foo'); }); }); describe('#isInDefaultSpace', () => { it('returns true when in the default space', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request: KibanaRequest = { url: { pathname: '/app/kibana' }, } as KibanaRequest; - expect(spacesServiceSetup.isInDefaultSpace(request)).toEqual(true); + expect(spacesServiceStart.isInDefaultSpace(request)).toEqual(true); }); it('returns false when not in the default space', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request: KibanaRequest = { url: { pathname: '/s/foo/app/kibana' }, } as KibanaRequest; - expect(spacesServiceSetup.isInDefaultSpace(request)).toEqual(false); + expect(spacesServiceStart.isInDefaultSpace(request)).toEqual(false); }); }); describe('#spaceIdToNamespace', () => { it('returns the namespace for the given space', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceSetup } = createService(); expect(spacesServiceSetup.spaceIdToNamespace('foo')).toEqual('foo'); }); }); describe('#namespaceToSpaceId', () => { it('returns the space id for the given namespace', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceSetup } = createService(); expect(spacesServiceSetup.namespaceToSpaceId('foo')).toEqual('foo'); }); }); describe('#getActiveSpace', () => { it('returns the default space when in the default space', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request = httpServerMock.createKibanaRequest({ path: 'app/kibana' }); - const activeSpace = await spacesServiceSetup.getActiveSpace(request); + const activeSpace = await spacesServiceStart.getActiveSpace(request); expect(activeSpace).toEqual({ id: 'space:default', name: 'Default Space', @@ -186,10 +161,10 @@ describe('SpacesService', () => { }); it('returns the space for the current (non-default) space', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/app/kibana' }); - const activeSpace = await spacesServiceSetup.getActiveSpace(request); + const activeSpace = await spacesServiceStart.getActiveSpace(request); expect(activeSpace).toEqual({ id: 'space:foo', name: 'Foo Space', @@ -198,11 +173,11 @@ describe('SpacesService', () => { }); it('propagates errors from the repository', async () => { - const spacesServiceSetup = await createService(); + const { spacesServiceStart } = createService(); const request = httpServerMock.createKibanaRequest({ path: '/s/unknown-space/app/kibana' }); await expect( - spacesServiceSetup.getActiveSpace(request) + spacesServiceStart.getActiveSpace(request) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Saved object [space/unknown-space] not found"` ); diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts index 3630675a7ed3f..d1e02c4162838 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts @@ -4,133 +4,128 @@ * you may not use this file except in compliance with the Elastic License. */ -import { map, take } from 'rxjs/operators'; -import { Observable, Subscription } from 'rxjs'; -import { Legacy } from 'kibana'; -import { Logger, KibanaRequest, CoreSetup } from '../../../../../src/core/server'; -import { SecurityPluginSetup } from '../../../security/server'; -import { SpacesClient } from '../lib/spaces_client'; -import { ConfigType } from '../config'; -import { getSpaceIdFromPath, addSpaceIdToPath } from '../../common/lib/spaces_url_parser'; +import type { KibanaRequest, IBasePath } from 'src/core/server'; +import { SpacesClientServiceStart } from '../spaces_client'; +import { getSpaceIdFromPath } from '../../common'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import { spaceIdToNamespace, namespaceToSpaceId } from '../lib/utils/namespace'; -import { Space } from '../../common/model/space'; -import { SpacesAuditLogger } from '../lib/audit_logger'; - -type RequestFacade = KibanaRequest | Legacy.Request; +import { Space } from '..'; export interface SpacesServiceSetup { - scopedClient(request: RequestFacade): Promise; - - getSpaceId(request: RequestFacade): string; - - getBasePath(spaceId: string): string; + /** + * Retrieves the space id associated with the provided request. + * @param request + * + * @deprecated Use `getSpaceId` from the `SpacesServiceStart` contract instead. + */ + getSpaceId(request: KibanaRequest): string; + + /** + * Converts the provided space id into the corresponding Saved Objects `namespace` id. + * @param spaceId + * + * @deprecated use `spaceIdToNamespace` from the `SpacesServiceStart` contract instead. + */ + spaceIdToNamespace(spaceId: string): string | undefined; - isInDefaultSpace(request: RequestFacade): boolean; + /** + * Converts the provided namespace into the corresponding space id. + * @param namespace + * + * @deprecated use `namespaceToSpaceId` from the `SpacesServiceStart` contract instead. + */ + namespaceToSpaceId(namespace: string | undefined): string; +} +export interface SpacesServiceStart { + /** + * Creates a scoped instance of the SpacesClient. + */ + createSpacesClient: SpacesClientServiceStart['createSpacesClient']; + + /** + * Retrieves the space id associated with the provided request. + * @param request + */ + getSpaceId(request: KibanaRequest): string; + + /** + * Indicates if the provided request is executing within the context of the `default` space. + * @param request + */ + isInDefaultSpace(request: KibanaRequest): boolean; + + /** + * Retrieves the Space associated with the provided request. + * @param request + */ + getActiveSpace(request: KibanaRequest): Promise; + + /** + * Converts the provided space id into the corresponding Saved Objects `namespace` id. + * @param spaceId + */ spaceIdToNamespace(spaceId: string): string | undefined; + /** + * Converts the provided namespace into the corresponding space id. + * @param namespace + */ namespaceToSpaceId(namespace: string | undefined): string; +} - getActiveSpace(request: RequestFacade): Promise; +interface SpacesServiceSetupDeps { + basePath: IBasePath; } -interface SpacesServiceDeps { - http: CoreSetup['http']; - getStartServices: CoreSetup['getStartServices']; - authorization: SecurityPluginSetup['authz'] | null; - config$: Observable; - auditLogger: SpacesAuditLogger; +interface SpacesServiceStartDeps { + basePath: IBasePath; + spacesClientService: SpacesClientServiceStart; } export class SpacesService { - private configSubscription$?: Subscription; - - constructor(private readonly log: Logger) {} - - public async setup({ - http, - getStartServices, - authorization, - config$, - auditLogger, - }: SpacesServiceDeps): Promise { - const getSpaceId = (request: RequestFacade) => { - // Currently utilized by reporting - const isFakeRequest = typeof (request as any).getBasePath === 'function'; - - const basePath = isFakeRequest - ? (request as Record).getBasePath() - : http.basePath.get(request); - - const { spaceId } = getSpaceIdFromPath(basePath, http.basePath.serverBasePath); - - return spaceId; - }; - - const internalRepositoryPromise = getStartServices().then(([coreStart]) => - coreStart.savedObjects.createInternalRepository(['space']) - ); - - const getScopedClient = async (request: KibanaRequest) => { - const [coreStart] = await getStartServices(); - const internalRepository = await internalRepositoryPromise; - - return config$ - .pipe( - take(1), - map((config) => { - const callWithRequestRepository = coreStart.savedObjects.createScopedRepository( - request, - ['space'] - ); - - return new SpacesClient( - auditLogger, - (message: string) => { - this.log.debug(message); - }, - authorization, - callWithRequestRepository, - config, - internalRepository, - request - ); - }) - ) - .toPromise(); + public setup({ basePath }: SpacesServiceSetupDeps): SpacesServiceSetup { + return { + getSpaceId: (request: KibanaRequest) => { + return this.getSpaceId(request, basePath); + }, + spaceIdToNamespace, + namespaceToSpaceId, }; + } + public start({ basePath, spacesClientService }: SpacesServiceStartDeps) { return { - getSpaceId, - getBasePath: (spaceId: string) => { - if (!spaceId) { - throw new TypeError(`spaceId is required to retrieve base path`); - } - return addSpaceIdToPath(http.basePath.serverBasePath, spaceId); + getSpaceId: (request: KibanaRequest) => { + return this.getSpaceId(request, basePath); + }, + + getActiveSpace: (request: KibanaRequest) => { + const spaceId = this.getSpaceId(request, basePath); + return spacesClientService.createSpacesClient(request).get(spaceId); }, - isInDefaultSpace: (request: RequestFacade) => { - const spaceId = getSpaceId(request); + + isInDefaultSpace: (request: KibanaRequest) => { + const spaceId = this.getSpaceId(request, basePath); return spaceId === DEFAULT_SPACE_ID; }, + + createSpacesClient: (request: KibanaRequest) => + spacesClientService.createSpacesClient(request), + spaceIdToNamespace, namespaceToSpaceId, - scopedClient: getScopedClient, - getActiveSpace: async (request: RequestFacade) => { - const spaceId = getSpaceId(request); - const spacesClient = await getScopedClient( - request instanceof KibanaRequest ? request : KibanaRequest.from(request) - ); - return spacesClient.get(spaceId); - }, }; } - public async stop() { - if (this.configSubscription$) { - this.configSubscription$.unsubscribe(); - this.configSubscription$ = undefined; - } + public stop() {} + + private getSpaceId(request: KibanaRequest, basePathService: IBasePath) { + const basePath = basePathService.get(request); + + const { spaceId } = getSpaceIdFromPath(basePath, basePathService.serverBasePath); + + return spaceId; } } diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index 2a6b2c0e69d1d..849e91a785048 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -167,7 +167,7 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(resp.body).to.eql({ error: 'Bad Request', statusCode: 400, - message: `This Space cannot be deleted because it is reserved.`, + message: `The default space cannot be deleted because it is reserved.`, }); };