Removing circular dependency between spaces and security (#81891)

* Removing circular dependency between spaces and security

* Apply suggestions from code review

Co-authored-by: Constance <constancecchen@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>

* Tests refactor

- Reorganize top level describes into 3 space-based blocks into based on spaces:
  - space disabled
  - spaces plugin unavailable
  - space enabled (most previous tests go under this new block) with new beforeEach

- wrote new tests for uncovered lines 58, 66-69

* Review1: address PR feedback

* changing fake requests for alerts/actions

* Fixing tests

* fixing more tests

* Additional testing and refactoring

* Apply suggestions from code review

Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>

* Review 2: Address feedback

* Make ESLint happy again

Co-authored-by: Constance <constancecchen@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Constance Chen <constance.chen.3@gmail.com>
This commit is contained in:
Larry Gregory 2020-11-19 13:41:13 -05:00 committed by GitHub
parent 1d5701d209
commit 7f962e5839
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 2894 additions and 2553 deletions

View file

@ -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({

View file

@ -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,

View file

@ -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 () => {

View file

@ -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<unknown>;
try {

View file

@ -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<Promise<PluginSetupContract>, 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<Promise<PluginSetupContract>, 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<Promise<PluginSetupContract>, 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<Promise<PluginSetupContract>, 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<Promise<PluginSetupContract>, 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();

View file

@ -22,7 +22,6 @@ export { ActionTypeExecutorResult } from '../common';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type GetServicesFunction = (request: KibanaRequest) => Services;
export type ActionTypeRegistryContract = PublicMethodsOf<ActionTypeRegistry>;
export type GetBasePathFunction = (spaceId?: string) => string;
export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined;
export type ActionTypeConfig = Record<string, unknown>;
export type ActionTypeSecrets = Record<string, unknown>;

View file

@ -158,7 +158,6 @@ describe('Alerting Plugin', () => {
getActionsClientWithRequest: jest.fn(),
getActionsAuthorizationWithRequest: jest.fn(),
},
spaces: () => null,
encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
features: mockFeatures(),
} as unknown) as AlertingPluginsStart

View file

@ -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<PluginSetupContract> {
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

View file

@ -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 () => {

View file

@ -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(

View file

@ -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(),
};

View file

@ -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;
}

View file

@ -32,7 +32,6 @@ import {
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type GetServicesFunction = (request: KibanaRequest) => Services;
export type GetBasePathFunction = (spaceId?: string) => string;
export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined;
declare module 'src/core/server' {

View file

@ -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"]

View file

@ -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 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,
});
});
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({
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,
});
});
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', () => {
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,
});
});
});
describe('when getActiveSpace returns 403 forbidden', () => {
it('should deny all access', async () => {
const config = { host: undefined };
expect(await checkAccess({ ...mockDependencies, config })).toEqual({
mockSpaces.spacesService.getActiveSpace.mockReturnValueOnce(
Promise.reject({ output: { statusCode: 403 } })
);
expect(await checkAccess({ ...mockDependencies })).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,
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 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 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,
});
});
});
});
});
});

View file

@ -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<Access> => {
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<boolean> => {
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) {

View file

@ -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<PluginsStart>,
{ 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;

View file

@ -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) {

View file

@ -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<PluginClusterClient>;
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,

View file

@ -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<IEventLogService, IEventLogClientService> {
@ -53,7 +53,6 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
private eventLogger?: IEventLogger;
private globalConfig$: Observable<SharedGlobalConfig>;
private eventLogClientService?: EventLogClientService;
private spacesService?: SpacesServiceSetup;
private savedObjectProviderRegistry: SavedObjectProviderRegistry;
constructor(private readonly context: PluginInitializerContext) {
@ -63,14 +62,13 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
this.savedObjectProviderRegistry = new SavedObjectProviderRegistry();
}
async setup(core: CoreSetup, { spaces }: PluginSetupDeps): Promise<IEventLogService> {
async setup(core: CoreSetup): Promise<IEventLogService> {
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<IEventLogService, IEventLogClientServi
return this.eventLogService;
}
async start(core: CoreStart): Promise<IEventLogClientService> {
async start(core: CoreStart, { spaces }: PluginStartDeps): Promise<IEventLogClientService> {
this.systemLogger.debug('starting plugin');
if (!this.esContext) throw new Error('esContext not initialized');
@ -131,7 +129,7 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
this.eventLogClientService = new EventLogClientService({
esContext: this.esContext,
savedObjectProviderRegistry: this.savedObjectProviderRegistry,
spacesService: this.spacesService,
spacesService: spaces?.spacesService,
});
return this.eventLogClientService;
}

View file

@ -5,29 +5,33 @@
*/
import { Legacy } from 'kibana';
import { KibanaRequest } from 'kibana/server';
import { SpacesPluginSetup } from '../../../spaces/server';
import { KibanaRequest } from '../../../../../src/core/server';
import { SpacesPluginStart } from '../../../spaces/server';
export type RequestFacade = KibanaRequest | Legacy.Request;
export function spacesUtilsProvider(
spacesPlugin: SpacesPluginSetup | undefined,
getSpacesPlugin: (() => Promise<SpacesPluginStart>) | undefined,
request: RequestFacade
) {
async function isMlEnabledInSpace(): Promise<boolean> {
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<string[] | null> {
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);
}

View file

@ -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<MlPluginSetup, MlPluginStart, PluginsSetup> {
export class MlServerPlugin
implements Plugin<MlPluginSetup, MlPluginStart, PluginsSetup, PluginsStart> {
private log: Logger;
private version: string;
private mlLicense: MlLicense;
@ -80,7 +81,7 @@ export class MlServerPlugin implements Plugin<MlPluginSetup, MlPluginStart, Plug
this.isMlReady = new Promise((resolve) => (this.setMlReady = resolve));
}
public setup(coreSetup: CoreSetup, plugins: PluginsSetup): MlPluginSetup {
public setup(coreSetup: CoreSetup<PluginsStart>, 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<MlPluginSetup, MlPluginStart, Plug
return capabilities.ml as MlCapabilities;
};
const getSpaces = plugins.spaces
? () => coreSetup.getStartServices().then(([, { spaces }]) => spaces!)
: undefined;
annotationRoutes(routeInit, plugins.security);
calendars(routeInit);
dataFeedRoutes(routeInit);
@ -175,7 +180,7 @@ export class MlServerPlugin implements Plugin<MlPluginSetup, MlPluginStart, Plug
jobValidationRoutes(routeInit, this.version);
savedObjectsRoutes(routeInit);
systemRoutes(routeInit, {
spaces: plugins.spaces,
getSpaces,
cloud: plugins.cloud,
resolveMlCapabilities,
});
@ -187,7 +192,7 @@ export class MlServerPlugin implements Plugin<MlPluginSetup, MlPluginStart, Plug
return {
...createSharedServices(
this.mlLicense,
plugins.spaces,
getSpaces,
plugins.cloud,
plugins.security?.authz,
resolveMlCapabilities,

View file

@ -5,8 +5,6 @@
*/
import { schema } from '@kbn/config-schema';
import { Request } from '@hapi/hapi';
import { IScopedClusterClient } from 'kibana/server';
import { wrapError } from '../client/error_wrapper';
import { mlLog } from '../lib/log';
@ -19,7 +17,7 @@ import { RouteInitialization, SystemRouteDeps } from '../types';
*/
export function systemRoutes(
{ router, mlLicense, routeGuard }: RouteInitialization,
{ spaces, cloud, resolveMlCapabilities }: SystemRouteDeps
{ getSpaces, cloud, resolveMlCapabilities }: SystemRouteDeps
) {
async function getNodeCount(client: IScopedClusterClient) {
const { body } = await client.asInternalUser.nodes.info({
@ -117,7 +115,7 @@ export function systemRoutes(
},
routeGuard.basicLicenseAPIGuard(async ({ mlClient, request, response }) => {
try {
const { isMlEnabledInSpace } = spacesUtilsProvider(spaces, (request as unknown) as Request);
const { isMlEnabledInSpace } = spacesUtilsProvider(getSpaces, request);
const mlCapabilities = await resolveMlCapabilities(request);
if (mlCapabilities === null) {

View file

@ -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<SpacesPluginStart>) | 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) {

View file

@ -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<SpacesPluginStart>) | 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),
};
}

View file

@ -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<SpacesPluginStart>;
resolveMlCapabilities: ResolveMlCapabilities;
}
@ -41,6 +41,10 @@ export interface PluginsSetup {
usageCollection: UsageCollectionSetup;
}
export interface PluginsStart {
spaces?: SpacesPluginStart;
}
export interface RouteInitialization {
router: IRouter;
mlLicense: MlLicense;

View file

@ -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": [

View file

@ -114,7 +114,6 @@ describe('Security Plugin', () => {
"isEnabled": [Function],
"isLicenseAvailable": [Function],
},
"registerSpacesService": [Function],
}
`);
});

View file

@ -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<PluginStartDependencies>,
{ features, licensing, taskManager, usageCollection, securityOss }: PluginSetupDependencies
{
features,
licensing,
taskManager,
usageCollection,
securityOss,
spaces,
}: PluginSetupDependencies
) {
const [config, legacyConfig] = await combineLatest([
this.initializerContext.config.create<TypeOf<typeof ConfigSchema>>().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';
}
}

View file

@ -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);
}

View file

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

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
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<IRouter>;
let routeParamsMock: DeeplyMockedKeys<RouteDefinitionParams>;
const mockContext = ({
licensing: {
license: { check: jest.fn().mockReturnValue({ state: 'valid' }) },
},
} as unknown) as RequestHandlerContext;
beforeEach(() => {
routeParamsMock = routeDefinitionParamsMock.create();
router = routeParamsMock.router as jest.Mocked<IRouter>;
defineShareSavedObjectPermissionRoutes(routeParamsMock);
});
describe('GET /internal/security/_share_saved_object_permissions', () => {
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
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'),
});
});
});
});

View file

@ -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 } });
})
);
}

View file

@ -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<string, unknown> = {}) => ({
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<string, unknown> = {}) =>
(({
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<RouteDefinitionParams>),
};

View file

@ -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';

View file

@ -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';

View file

@ -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',

View file

@ -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<LegacySpacesAuditLogger>;
const request = httpServerMock.createKibanaRequest();
const wrapper = new SecureSpacesClientWrapper(
baseClient,
request,
authorization,
legacyAuditLogger
);
return {
authorization,
wrapper,
request,
baseClient,
legacyAuditLogger,
};
};
const expectNoAuthorizationCheck = (authorization: jest.Mocked<AuthorizationServiceSetup>) => {
expect(authorization.checkPrivilegesDynamicallyWithRequest).not.toHaveBeenCalled();
expect(authorization.checkPrivilegesWithRequest).not.toHaveBeenCalled();
expect(authorization.checkSavedObjectsPrivilegesWithRequest).not.toHaveBeenCalled();
};
const expectNoAuditLogging = (auditLogger: jest.Mocked<LegacySpacesAuditLogger>) => {
expect(auditLogger.spacesAuthorizationFailure).not.toHaveBeenCalled();
expect(auditLogger.spacesAuthorizationSuccess).not.toHaveBeenCalled();
};
const expectForbiddenAuditLogging = (
auditLogger: jest.Mocked<LegacySpacesAuditLogger>,
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<LegacySpacesAuditLogger>,
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');
});
});
});

View file

@ -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<GetSpaceResult[]> {
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<GetAllSpacesPurpose, string[]>
);
// 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<GetAllSpacesPurpose, boolean>
);
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;
}
}

View file

@ -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']);
});
});

View file

@ -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)
);
};

View file

@ -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 /"`;

View file

@ -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 /"`);
});
});

View file

@ -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) {

View file

@ -8,7 +8,6 @@
"advancedSettings",
"home",
"management",
"security",
"usageCollection",
"savedObjectsManagement"
],

View file

@ -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' },
}
);
});
});
});

View file

@ -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<void> {

View file

@ -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<PluginsStart>,
spacesService,
() => spacesService,
logger
);

View file

@ -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<PluginsStart>,
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(),
]);

View file

@ -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<PluginsStart>,
spacesService: SpacesServiceSetup,
getSpacesService: () => SpacesServiceStart,
logger: Logger
) => {
core.capabilities.registerProvider(capabilitiesProvider);
core.capabilities.registerSwitcher(setupCapabilitiesSwitcher(core, spacesService, logger));
core.capabilities.registerSwitcher(setupCapabilitiesSwitcher(core, getSpacesService, logger));
};

View file

@ -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 };

View file

@ -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,
});
spacesService.scopedClient = jest.fn().mockResolvedValue({
const spacesServiceStart = service.start({
basePath: http.basePath,
spacesClientService: spacesClientServiceMock.createStart(),
});
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,

View file

@ -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);

View file

@ -1,309 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 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<GetSpaceResult[]> {
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<GetAllSpacesPurpose, string[]>
);
// 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<GetAllSpacesPurpose, boolean>
);
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<Space> {
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;
}
}

View file

@ -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 contextFactory = createSpacesTutorialContextFactory(() =>
service.start({
basePath: coreMock.createStart().http.basePath,
spacesClientService: spacesClientServiceMock.createStart(),
})
);
const request = {};
const request = httpServerMock.createKibanaRequest();
expect(contextFactory(request)).toEqual({
spaceId: DEFAULT_SPACE_ID,

View file

@ -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),

View file

@ -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';

View file

@ -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<PluginsStart>;
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<PluginsStart>;
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<PluginsStart>;
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<PluginsStart>;
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<PluginsStart>;
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],
},
}
`);
});
});
});

View file

@ -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 interface SpacesPluginStart {
spacesService: SpacesServiceStart;
}
export class Plugin {
private readonly pluginId = 'spaces';
private readonly config$: Observable<ConfigType>;
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<ConfigType>();
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 setup(core: CoreSetup<PluginsStart>, plugins: PluginsSetup): SpacesPluginSetup {
const spacesClientSetup = this.spacesClientService.setup({ config$: this.config$ });
public async setup(
core: CoreSetup<PluginsStart>,
plugins: PluginsSetup
): Promise<SpacesPluginSetup> {
const service = new SpacesService(this.log);
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,
};
}

View file

@ -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<SavedObjectsClientContract>;
} as unknown) as jest.Mocked<ISavedObjectsRepository>;
return mockSavedObjectsClientContract;
};

View file

@ -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 [

View file

@ -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,
{

View file

@ -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.');
});
});

View file

@ -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;

View file

@ -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 {

View file

@ -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);

View file

@ -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<any>;
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]'
);
}
});

View file

@ -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[];

View file

@ -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) {

View file

@ -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];

View file

@ -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;

View file

@ -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];

View file

@ -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;

View file

@ -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);
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,
});
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' };

View file

@ -13,7 +13,7 @@ import { createLicensedRouteHandler } from '../../lib';
const uniq = <T>(arr: T[]): T[] => Array.from(new Set<T>(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) => {

View file

@ -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 {

View file

@ -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));

View file

@ -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) {

View file

@ -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,
});
}

View file

@ -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(

View file

@ -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<CoreSetup, 'savedObjects' | 'getStartServices'>;
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)
);
}
}

View file

@ -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));

View file

@ -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<SpacesClient>;
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 {

View file

@ -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';

View file

@ -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 = () =>

View file

@ -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);
});
});

View file

@ -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<SpacesClient>;
export class SpacesClient {
constructor(
private readonly debugLogger: (message: string) => void,
private readonly config: ConfigType,
private readonly repository: ISavedObjectsRepository
) {}
public async getAll(options: GetAllSpacesOptions = {}): Promise<GetSpaceResult[]> {
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<any>) {
return {
id: savedObject.id,
...savedObject.attributes,
} as Space;
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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<SpacesClientServiceSetup>);
const createSpacesClientServiceStartMock = () =>
({
createSpacesClient: jest.fn().mockReturnValue(spacesClientMock.create()),
} as jest.Mocked<SpacesClientServiceStart>);
export const spacesClientServiceMock = {
createSetup: createSpacesClientServiceSetupMock,
createStart: createSpacesClientServiceStartMock,
};

View file

@ -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<ConfigType>() });
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);
});
});
});

View file

@ -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<ConfigType>;
}
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;
},
};
}
}

View file

@ -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';

View file

@ -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<SpacesServiceSetup> = {
getSpaceId: jest.fn().mockReturnValue(spaceId),
isInDefaultSpace: jest.fn().mockReturnValue(spaceId === DEFAULT_SPACE_ID),
getBasePath: jest.fn().mockReturnValue(''),
scopedClient: jest.fn().mockResolvedValue(spacesClientMock.create()),
namespaceToSpaceId: jest.fn().mockImplementation(namespaceToSpaceId),
spaceIdToNamespace: jest.fn().mockImplementation(spaceIdToNamespace),
getActiveSpace: jest.fn(),
getSpaceId: jest.fn().mockReturnValue(spaceId),
};
return setupContract;
};
const createStartContractMock = (spaceId = DEFAULT_SPACE_ID) => {
const startContract: jest.Mocked<SpacesServiceStart> = {
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 startContract;
};
export const spacesServiceMock = {
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
};

View file

@ -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, {}, {}],
config$: Rx.of(spacesConfig),
authorization: securityMock.createSetup().authz,
auditLogger: new SpacesAuditLogger(),
coreStart.http.basePath = httpSetup.basePath;
const spacesServiceSetup = spacesService.setup({
basePath: httpSetup.basePath,
});
return spacesServiceSetup;
const spacesClientService = new SpacesClientService(jest.fn());
spacesClientService.setup({
config$: Rx.of(spacesConfig),
});
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"`
);

View file

@ -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<SpacesClient>;
getSpaceId(request: RequestFacade): string;
getBasePath(spaceId: string): string;
isInDefaultSpace(request: RequestFacade): boolean;
/**
* 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;
/**
* Converts the provided namespace into the corresponding space id.
* @param namespace
*
* @deprecated use `namespaceToSpaceId` from the `SpacesServiceStart` contract instead.
*/
namespaceToSpaceId(namespace: string | undefined): string;
getActiveSpace(request: RequestFacade): Promise<Space>;
}
interface SpacesServiceDeps {
http: CoreSetup['http'];
getStartServices: CoreSetup['getStartServices'];
authorization: SecurityPluginSetup['authz'] | null;
config$: Observable<ConfigType>;
auditLogger: SpacesAuditLogger;
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<Space>;
/**
* 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;
}
interface SpacesServiceSetupDeps {
basePath: IBasePath;
}
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<SpacesServiceSetup> {
const getSpaceId = (request: RequestFacade) => {
// Currently utilized by reporting
const isFakeRequest = typeof (request as any).getBasePath === 'function';
const basePath = isFakeRequest
? (request as Record<string, any>).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,
getBasePath: (spaceId: string) => {
if (!spaceId) {
throw new TypeError(`spaceId is required to retrieve base path`);
}
return addSpaceIdToPath(http.basePath.serverBasePath, spaceId);
},
isInDefaultSpace: (request: RequestFacade) => {
const spaceId = getSpaceId(request);
return spaceId === DEFAULT_SPACE_ID;
getSpaceId: (request: KibanaRequest) => {
return this.getSpaceId(request, basePath);
},
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 start({ basePath, spacesClientService }: SpacesServiceStartDeps) {
return {
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: KibanaRequest) => {
const spaceId = this.getSpaceId(request, basePath);
return spaceId === DEFAULT_SPACE_ID;
},
createSpacesClient: (request: KibanaRequest) =>
spacesClientService.createSpacesClient(request),
spaceIdToNamespace,
namespaceToSpaceId,
};
}
public stop() {}
private getSpaceId(request: KibanaRequest, basePathService: IBasePath) {
const basePath = basePathService.get(request);
const { spaceId } = getSpaceIdFromPath(basePath, basePathService.serverBasePath);
return spaceId;
}
}

View file

@ -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.`,
});
};