Fix saved object share UI bugs regarding read-only privileges (#81828)

This commit is contained in:
Joe Portner 2020-11-03 23:07:39 -05:00 committed by GitHub
parent 286dbcae3a
commit fb1c7d7048
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1370 additions and 940 deletions

View file

@ -11,6 +11,20 @@ experimental[] Retrieve all {kib} spaces.
`GET <kibana host>:<port>/api/spaces/space`
[[spaces-api-get-all-query-params]]
==== Query parameters
`purpose`::
(Optional, string) Valid options include `any`, `copySavedObjectsIntoSpace`, and `shareSavedObjectsIntoSpace`. This determines what
authorization checks are applied to the API call. If `purpose` is not provided in the URL, the `any` purpose is used.
`include_authorized_purposes`::
(Optional, boolean) When enabled, the API will return any spaces that the user is authorized to access in any capacity, and each space
will contain the purpose(s) for which the user is authorized. This can be useful to determine which spaces a user can read but not take a
specific action in. If the Security plugin is not enabled, this will have no effect, as no authorization checks would take place.
+
NOTE: This option cannot be used in conjunction with `purpose`.
[[spaces-api-get-all-response-codes]]
==== Response code
@ -18,7 +32,17 @@ experimental[] Retrieve all {kib} spaces.
Indicates a successful call.
[[spaces-api-get-all-example]]
==== Example
==== Examples
[[spaces-api-get-all-example-1]]
===== Default options
Retrieve all spaces without specifying any options:
[source,sh]
--------------------------------------------------
$ curl -X GET api/spaces/space
--------------------------------------------------
The API returns the following:
@ -51,3 +75,63 @@ The API returns the following:
}
]
--------------------------------------------------
[[spaces-api-get-all-example-2]]
===== Custom options
The user has read-only access to the Sales space. Retrieve all spaces and specify options:
[source,sh]
--------------------------------------------------
$ curl -X GET api/spaces/space?purpose=shareSavedObjectsIntoSpace&include_authorized_purposes=true
--------------------------------------------------
The API returns the following:
[source,sh]
--------------------------------------------------
[
{
"id": "default",
"name": "Default",
"description" : "This is the Default Space",
"disabledFeatures": [],
"imageUrl": "",
"_reserved": true,
"authorizedPurposes": {
"any": true,
"copySavedObjectsIntoSpace": true,
"findSavedObjects": true,
"shareSavedObjectsIntoSpace": true,
}
},
{
"id": "marketing",
"name": "Marketing",
"description" : "This is the Marketing Space",
"color": "#aabbcc",
"disabledFeatures": ["apm"],
"initials": "MK",
"imageUrl": "data:image/png;base64,iVBORw0KGgoAAAANSU",
"authorizedPurposes": {
"any": true,
"copySavedObjectsIntoSpace": true,
"findSavedObjects": true,
"shareSavedObjectsIntoSpace": true,
}
},
{
"id": "sales",
"name": "Sales",
"initials": "MK",
"disabledFeatures": ["discover", "timelion"],
"imageUrl": "",
"authorizedPurposes": {
"any": true,
"copySavedObjectsIntoSpace": false,
"findSavedObjects": true,
"shareSavedObjectsIntoSpace": false,
}
}
]
--------------------------------------------------

View file

@ -266,8 +266,19 @@ exports[`SavedObjectsTable should render normally 1`] = `
"serverBasePath": "",
}
}
canDelete={false}
canGoInApp={[Function]}
capabilities={
Object {
"catalogue": Object {},
"management": Object {},
"navLinks": Object {},
"savedObjectsManagement": Object {
"delete": false,
"edit": false,
"read": true,
},
}
}
columnRegistry={
Object {
"getAll": [MockFunction],

View file

@ -24,7 +24,6 @@ import { keys } from '@elastic/eui';
import { httpServiceMock } from '../../../../../../core/public/mocks';
import { actionServiceMock } from '../../../services/action_service.mock';
import { columnServiceMock } from '../../../services/column_service.mock';
import { SavedObjectsManagementAction } from '../../..';
import { Table, TableProps } from './table';
const defaultProps: TableProps = {
@ -82,7 +81,7 @@ const defaultProps: TableProps = {
onTableChange: () => {},
isSearching: false,
onShowRelationships: () => {},
canDelete: true,
capabilities: { savedObjectsManagement: { delete: true } } as any,
};
describe('Table', () => {
@ -121,7 +120,11 @@ describe('Table', () => {
{ type: 'search' },
{ type: 'index-pattern' },
] as any;
const customizedProps = { ...defaultProps, selectedSavedObjects, canDelete: false };
const customizedProps = {
...defaultProps,
selectedSavedObjects,
capabilities: { savedObjectsManagement: { delete: false } } as any,
};
const component = shallowWithI18nProvider(<Table {...customizedProps} />);
expect(component).toMatchSnapshot();
@ -137,7 +140,8 @@ describe('Table', () => {
refreshOnFinish: () => true,
euiAction: { name: 'foo', description: 'bar', icon: 'beaker', type: 'icon' },
registerOnFinishCallback: (callback: Function) => callback(), // call the callback immediately for this test
} as SavedObjectsManagementAction,
setActionContext: () => null,
} as any,
]);
const onActionRefresh = jest.fn();
const customizedProps = { ...defaultProps, actionRegistry, onActionRefresh };

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { IBasePath } from 'src/core/public';
import { ApplicationStart, IBasePath } from 'src/core/public';
import React, { PureComponent, Fragment } from 'react';
import {
EuiSearchBar,
@ -57,7 +57,7 @@ export interface TableProps {
onSelectionChange: (selection: SavedObjectWithMetadata[]) => void;
};
filterOptions: any[];
canDelete: boolean;
capabilities: ApplicationStart['capabilities'];
onDelete: () => void;
onActionRefresh: (object: SavedObjectWithMetadata) => void;
onExport: (includeReferencesDeep: boolean) => void;
@ -156,6 +156,7 @@ export class Table extends PureComponent<TableProps, TableState> {
isSearching,
filterOptions,
selectionConfig: selection,
capabilities,
onDelete,
onActionRefresh,
selectedSavedObjects,
@ -285,6 +286,7 @@ export class Table extends PureComponent<TableProps, TableState> {
'data-test-subj': 'savedObjectsTableAction-relationships',
},
...actionRegistry.getAll().map((action) => {
action.setActionContext({ capabilities });
return {
...action.euiAction,
'data-test-subj': `savedObjectsTableAction-${action.id}`,
@ -354,9 +356,11 @@ export class Table extends PureComponent<TableProps, TableState> {
iconType="trash"
color="danger"
onClick={onDelete}
isDisabled={selectedSavedObjects.length === 0 || !this.props.canDelete}
isDisabled={
selectedSavedObjects.length === 0 || !capabilities.savedObjectsManagement.delete
}
title={
this.props.canDelete
capabilities.savedObjectsManagement.delete
? undefined
: i18n.translate('savedObjectsManagement.objectsTable.table.deleteButtonTitle', {
defaultMessage: 'Unable to delete saved objects',

View file

@ -807,7 +807,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
onTableChange={this.onTableChange}
filterOptions={filterOptions}
onExport={this.onExport}
canDelete={applications.capabilities.savedObjectsManagement.delete as boolean}
capabilities={applications.capabilities}
onDelete={this.onDelete}
onActionRefresh={this.refreshObject}
goInspectObject={this.props.goInspectObject}

View file

@ -18,8 +18,13 @@
*/
import { ReactNode } from 'react';
import { Capabilities } from 'src/core/public';
import { SavedObjectsManagementRecord } from '.';
interface ActionContext {
capabilities: Capabilities;
}
export abstract class SavedObjectsManagementAction {
public abstract render: () => ReactNode;
public abstract id: string;
@ -37,8 +42,13 @@ export abstract class SavedObjectsManagementAction {
private callbacks: Function[] = [];
protected actionContext: ActionContext | null = null;
protected record: SavedObjectsManagementRecord | null = null;
public setActionContext(actionContext: ActionContext) {
this.actionContext = actionContext;
}
public registerOnFinishCallback(callback: Function) {
this.callbacks.push(callback);
}

View file

@ -4,8 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
export type GetSpacePurpose =
import { Space } from './space';
export type GetAllSpacesPurpose =
| 'any'
| 'copySavedObjectsIntoSpace'
| 'findSavedObjects'
| 'shareSavedObjectsIntoSpace';
export interface GetSpaceResult extends Space {
authorizedPurposes?: Record<GetAllSpacesPurpose, boolean>;
}

View file

@ -61,13 +61,16 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
}
);
useEffect(() => {
const getSpaces = spacesManager.getSpaces('copySavedObjectsIntoSpace');
const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true });
const getActiveSpace = spacesManager.getActiveSpace();
Promise.all([getSpaces, getActiveSpace])
.then(([allSpaces, activeSpace]) => {
setSpacesState({
isLoading: false,
spaces: allSpaces.filter((space) => space.id !== activeSpace.id),
spaces: allSpaces.filter(
({ id, authorizedPurposes }) =>
id !== activeSpace.id && authorizedPurposes?.copySavedObjectsIntoSpace !== false
),
});
})
.catch((e) => {

View file

@ -8,6 +8,8 @@ import { SpacesPlugin } from './plugin';
export { Space } from '../common/model/space';
export { GetSpaceResult } from '../common/model/types';
export { SpaceAvatar, getSpaceColor, getSpaceImageUrl, getSpaceInitials } from './space_avatar';
export { SpacesPluginSetup, SpacesPluginStart } from './plugin';

View file

@ -0,0 +1,39 @@
/*
* 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 React, { useState, useEffect, PropsWithChildren } from 'react';
import { StartServicesAccessor, CoreStart } from 'src/core/public';
import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public';
import { PluginsStart } from '../../plugin';
interface Props {
getStartServices: StartServicesAccessor<PluginsStart>;
}
export const ContextWrapper = (props: PropsWithChildren<Props>) => {
const { getStartServices, children } = props;
const [coreStart, setCoreStart] = useState<CoreStart>();
useEffect(() => {
getStartServices().then((startServices) => {
const [coreStartValue] = startServices;
setCoreStart(coreStartValue);
});
}, [getStartServices]);
if (!coreStart) {
return null;
}
const { application, docLinks } = coreStart;
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
application,
docLinks,
});
return <KibanaReactContextProvider>{children}</KibanaReactContextProvider>;
};

View file

@ -4,4 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ContextWrapper } from './context_wrapper';
export { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout';

View file

@ -11,6 +11,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiLink,
EuiSelectable,
EuiSelectableOption,
@ -35,6 +36,26 @@ interface Props {
type SpaceOption = EuiSelectableOption & { ['data-space-id']: string };
const ROW_HEIGHT = 40;
const partiallyAuthorizedTooltip = {
checked: i18n.translate(
'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked',
{ defaultMessage: 'You need additional privileges to deselect this space.' }
),
unchecked: i18n.translate(
'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked',
{ defaultMessage: 'You need additional privileges to select this space.' }
),
};
const partiallyAuthorizedSpaceProps = (checked: boolean) => ({
append: (
<EuiIconTip
content={checked ? partiallyAuthorizedTooltip.checked : partiallyAuthorizedTooltip.unchecked}
position="left"
type="iInCircle"
/>
),
disabled: true,
});
const activeSpaceProps = {
append: <EuiBadge color="hollow">Current</EuiBadge>,
disabled: true,
@ -46,22 +67,27 @@ export const SelectableSpacesControl = (props: Props) => {
const { services } = useKibana();
const { application, docLinks } = services;
const activeSpaceId = spaces.find((space) => space.isActiveSpace)!.id;
const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID);
const options = spaces
.sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0))
.map<SpaceOption>((space) => ({
label: space.name,
prepend: <SpaceAvatar space={space} size={'s'} />,
checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined,
['data-space-id']: space.id,
['data-test-subj']: `sts-space-selector-row-${space.id}`,
...(space.isActiveSpace ? activeSpaceProps : {}),
...(isGlobalControlChecked && { disabled: true }),
}));
.map<SpaceOption>((space) => {
const checked = selectedSpaceIds.includes(space.id);
return {
label: space.name,
prepend: <SpaceAvatar space={space} size={'s'} />,
checked: checked ? 'on' : undefined,
['data-space-id']: space.id,
['data-test-subj']: `sts-space-selector-row-${space.id}`,
...(isGlobalControlChecked && { disabled: true }),
...(space.isPartiallyAuthorized && partiallyAuthorizedSpaceProps(checked)),
...(space.isActiveSpace && activeSpaceProps),
};
});
function updateSelectedSpaces(spaceOptions: SpaceOption[]) {
const selectedOptions = spaceOptions
.filter(({ checked, disabled }) => checked && !disabled)
.filter((x) => x.checked && x['data-space-id'] !== activeSpaceId)
.map((x) => x['data-space-id']);
const updatedSpaceIds = [
...selectedOptions,

View file

@ -8,7 +8,7 @@ import Boom from '@hapi/boom';
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout';
import { ShareToSpaceForm } from './share_to_space_form';
import { EuiLoadingSpinner } from '@elastic/eui';
import { EuiLoadingSpinner, EuiSelectable } from '@elastic/eui';
import { Space } from '../../../common/model/space';
import { findTestSubject } from 'test_utils/find_test_subject';
import { SelectableSpacesControl } from './selectable_spaces_control';
@ -21,6 +21,7 @@ import { EuiCallOut } from '@elastic/eui';
import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components';
import { NoSpacesAvailable } from './no_spaces_available';
import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public';
import { ContextWrapper } from '.';
interface SetupOpts {
mockSpaces?: Space[];
@ -93,17 +94,26 @@ const setup = async (opts: SetupOpts = {}) => {
};
getStartServices.mockResolvedValue([startServices, , ,]);
// the flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper
// the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change
const wrapper = mountWithIntl(
<ShareSavedObjectsToSpaceFlyout
savedObject={savedObjectToShare}
spacesManager={(mockSpacesManager as unknown) as SpacesManager}
toastNotifications={(mockToastNotifications as unknown) as ToastsApi}
onClose={onClose}
onObjectUpdated={onObjectUpdated}
getStartServices={getStartServices}
/>
<ContextWrapper getStartServices={getStartServices}>
<ShareSavedObjectsToSpaceFlyout
savedObject={savedObjectToShare}
spacesManager={(mockSpacesManager as unknown) as SpacesManager}
toastNotifications={(mockToastNotifications as unknown) as ToastsApi}
onClose={onClose}
onObjectUpdated={onObjectUpdated}
/>
</ContextWrapper>
);
// wait for context wrapper to rerender
await act(async () => {
await nextTick();
wrapper.update();
});
if (!opts.returnBeforeSpacesLoad) {
// Wait for spaces manager to complete and flyout to rerender
await act(async () => {
@ -383,4 +393,95 @@ describe('ShareToSpaceFlyout', () => {
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
describe('space selection', () => {
const mockSpaces = [
{
// normal "fully authorized" space selection option -- not the active space
id: 'space-1',
name: 'Space 1',
disabledFeatures: [],
},
{
// "partially authorized" space selection option -- not the active space
id: 'space-2',
name: 'Space 2',
disabledFeatures: [],
authorizedPurposes: { shareSavedObjectsIntoSpace: false },
},
{
// "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top)
id: 'my-active-space',
name: 'my active space',
disabledFeatures: [],
},
];
const expectActiveSpace = (option: any) => {
expect(option.append).toMatchInlineSnapshot(`
<EuiBadge
color="hollow"
>
Current
</EuiBadge>
`);
// by definition, the active space will always be checked
expect(option.checked).toEqual('on');
expect(option.disabled).toEqual(true);
};
const expectInactiveSpace = (option: any, checked: boolean) => {
expect(option.append).toBeUndefined();
expect(option.checked).toEqual(checked ? 'on' : undefined);
expect(option.disabled).toBeUndefined();
};
const expectPartiallyAuthorizedSpace = (option: any, checked: boolean) => {
if (checked) {
expect(option.append).toMatchInlineSnapshot(`
<EuiIconTip
content="You need additional privileges to deselect this space."
position="left"
type="iInCircle"
/>
`);
} else {
expect(option.append).toMatchInlineSnapshot(`
<EuiIconTip
content="You need additional privileges to select this space."
position="left"
type="iInCircle"
/>
`);
}
expect(option.checked).toEqual(checked ? 'on' : undefined);
expect(option.disabled).toEqual(true);
};
it('correctly defines space selection options when spaces are not selected', async () => {
const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace
const { wrapper } = await setup({ mockSpaces, namespaces });
const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable);
const selectOptions = selectable.prop('options');
expect(selectOptions[0]['data-space-id']).toEqual('my-active-space');
expectActiveSpace(selectOptions[0]);
expect(selectOptions[1]['data-space-id']).toEqual('space-1');
expectInactiveSpace(selectOptions[1], false);
expect(selectOptions[2]['data-space-id']).toEqual('space-2');
expectPartiallyAuthorizedSpace(selectOptions[2], false);
});
it('correctly defines space selection options when spaces are selected', async () => {
const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces
const { wrapper } = await setup({ mockSpaces, namespaces });
const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable);
const selectOptions = selectable.prop('options');
expect(selectOptions[0]['data-space-id']).toEqual('my-active-space');
expectActiveSpace(selectOptions[0]);
expect(selectOptions[1]['data-space-id']).toEqual('space-1');
expectInactiveSpace(selectOptions[1], true);
expect(selectOptions[2]['data-space-id']).toEqual('space-2');
expectPartiallyAuthorizedSpace(selectOptions[2], true);
});
});
});

View file

@ -21,16 +21,14 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ToastsStart, StartServicesAccessor, CoreStart } from 'src/core/public';
import { ToastsStart } from 'src/core/public';
import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public';
import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public';
import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants';
import { Space } from '../../../common/model/space';
import { GetSpaceResult } from '../../../common/model/types';
import { SpacesManager } from '../../spaces_manager';
import { ShareToSpaceForm } from './share_to_space_form';
import { ShareOptions, SpaceTarget } from '../types';
import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components';
import { PluginsStart } from '../../plugin';
interface Props {
onClose: () => void;
@ -38,23 +36,14 @@ interface Props {
savedObject: SavedObjectsManagementRecord;
spacesManager: SpacesManager;
toastNotifications: ToastsStart;
getStartServices: StartServicesAccessor<PluginsStart>;
}
const arraysAreEqual = (a: unknown[], b: unknown[]) =>
a.every((x) => b.includes(x)) && b.every((x) => a.includes(x));
export const ShareSavedObjectsToSpaceFlyout = (props: Props) => {
const {
getStartServices,
onClose,
onObjectUpdated,
savedObject,
spacesManager,
toastNotifications,
} = props;
const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props;
const { namespaces: currentNamespaces = [] } = savedObject;
const [coreStart, setCoreStart] = useState<CoreStart>();
const [shareOptions, setShareOptions] = useState<ShareOptions>({ selectedSpaceIds: [] });
const [canShareToAllSpaces, setCanShareToAllSpaces] = useState<boolean>(false);
const [showMakeCopy, setShowMakeCopy] = useState<boolean>(false);
@ -64,20 +53,19 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => {
spaces: SpaceTarget[];
}>({ isLoading: true, spaces: [] });
useEffect(() => {
const getSpaces = spacesManager.getSpaces('shareSavedObjectsIntoSpace');
const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true });
const getActiveSpace = spacesManager.getActiveSpace();
const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type);
Promise.all([getSpaces, getActiveSpace, getPermissions, getStartServices()])
.then(([allSpaces, activeSpace, permissions, startServices]) => {
const [coreStartValue] = startServices;
setCoreStart(coreStartValue);
Promise.all([getSpaces, getActiveSpace, getPermissions])
.then(([allSpaces, activeSpace, permissions]) => {
setShareOptions({
selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id),
});
setCanShareToAllSpaces(permissions.shareToAllSpaces);
const createSpaceTarget = (space: Space): SpaceTarget => ({
const createSpaceTarget = (space: GetSpaceResult): SpaceTarget => ({
...space,
isActiveSpace: space.id === activeSpace.id,
isPartiallyAuthorized: space.authorizedPurposes?.shareSavedObjectsIntoSpace === false,
});
setSpacesState({
isLoading: false,
@ -91,7 +79,7 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => {
}),
});
});
}, [currentNamespaces, spacesManager, savedObject, toastNotifications, getStartServices]);
}, [currentNamespaces, spacesManager, savedObject, toastNotifications]);
const getSelectionChanges = () => {
const activeSpace = spaces.find((space) => space.isActiveSpace);
@ -208,23 +196,16 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => {
const activeSpace = spaces.find((x) => x.isActiveSpace)!;
const showShareWarning =
spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]);
const { application, docLinks } = coreStart!;
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
application,
docLinks,
});
// Step 2: Share has not been initiated yet; User must fill out form to continue.
return (
<KibanaReactContextProvider>
<ShareToSpaceForm
spaces={spaces}
shareOptions={shareOptions}
onUpdate={setShareOptions}
showShareWarning={showShareWarning}
canShareToAllSpaces={canShareToAllSpaces}
makeCopy={() => setShowMakeCopy(true)}
/>
</KibanaReactContextProvider>
<ShareToSpaceForm
spaces={spaces}
shareOptions={shareOptions}
onUpdate={setShareOptions}
showShareWarning={showShareWarning}
canShareToAllSpaces={canShareToAllSpaces}
makeCopy={() => setShowMakeCopy(true)}
/>
);
};

View file

@ -0,0 +1,70 @@
/*
* 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, notificationServiceMock } from 'src/core/public/mocks';
import { SavedObjectsManagementRecord } from '../../../../../src/plugins/saved_objects_management/public';
import { spacesManagerMock } from '../spaces_manager/mocks';
import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action';
describe('ShareToSpaceSavedObjectsManagementAction', () => {
const createAction = () => {
const spacesManager = spacesManagerMock.create();
const notificationsStart = notificationServiceMock.createStartContract();
const { getStartServices } = coreMock.createSetup();
return new ShareToSpaceSavedObjectsManagementAction(
spacesManager,
notificationsStart,
getStartServices
);
};
describe('#euiAction.available', () => {
describe('with an object type that has a namespaceType of "multiple"', () => {
const object = { meta: { namespaceType: 'multiple' } } as SavedObjectsManagementRecord;
it(`is available when UI capabilities are not set`, () => {
const action = createAction();
expect(action.euiAction.available(object)).toBe(true);
});
it(`is available when UI capabilities are set and shareIntoSpace is enabled`, () => {
const action = createAction();
const capabilities: any = { savedObjectsManagement: { shareIntoSpace: true } };
action.setActionContext({ capabilities });
expect(action.euiAction.available(object)).toBe(true);
});
it(`is not available when UI capabilities are set and shareIntoSpace is disabled`, () => {
const action = createAction();
const capabilities: any = { savedObjectsManagement: { shareIntoSpace: false } };
action.setActionContext({ capabilities });
expect(action.euiAction.available(object)).toBe(false);
});
});
describe('with an object type that does not have a namespaceType of "multiple"', () => {
const object = { meta: { namespaceType: 'single' } } as SavedObjectsManagementRecord;
it(`is not available when UI capabilities are not set`, () => {
const action = createAction();
expect(action.euiAction.available(object)).toBe(false);
});
it(`is not available when UI capabilities are set and shareIntoSpace is enabled`, () => {
const action = createAction();
const capabilities: any = { savedObjectsManagement: { shareIntoSpace: true } };
action.setActionContext({ capabilities });
expect(action.euiAction.available(object)).toBe(false);
});
it(`is not available when UI capabilities are set and shareIntoSpace is disabled`, () => {
const action = createAction();
const capabilities: any = { savedObjectsManagement: { shareIntoSpace: false } };
action.setActionContext({ capabilities });
expect(action.euiAction.available(object)).toBe(false);
});
});
});
});

View file

@ -10,7 +10,7 @@ import {
SavedObjectsManagementAction,
SavedObjectsManagementRecord,
} from '../../../../../src/plugins/saved_objects_management/public';
import { ShareSavedObjectsToSpaceFlyout } from './components';
import { ContextWrapper, ShareSavedObjectsToSpaceFlyout } from './components';
import { SpacesManager } from '../spaces_manager';
import { PluginsStart } from '../plugin';
@ -27,7 +27,10 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
icon: 'share',
type: 'icon',
available: (object: SavedObjectsManagementRecord) => {
return object.meta.namespaceType === 'multiple';
const hasCapability =
!this.actionContext ||
!!this.actionContext.capabilities.savedObjectsManagement.shareIntoSpace;
return object.meta.namespaceType === 'multiple' && hasCapability;
},
onClick: (object: SavedObjectsManagementRecord) => {
this.isDataChanged = false;
@ -52,14 +55,15 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
}
return (
<ShareSavedObjectsToSpaceFlyout
onClose={this.onClose}
onObjectUpdated={() => (this.isDataChanged = true)}
savedObject={this.record}
spacesManager={this.spacesManager}
toastNotifications={this.notifications.toasts}
getStartServices={this.getStartServices}
/>
<ContextWrapper getStartServices={this.getStartServices}>
<ShareSavedObjectsToSpaceFlyout
onClose={this.onClose}
onObjectUpdated={() => (this.isDataChanged = true)}
savedObject={this.record}
spacesManager={this.spacesManager}
toastNotifications={this.notifications.toasts}
/>
</ContextWrapper>
);
};

View file

@ -5,7 +5,7 @@
*/
import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public';
import { Space } from '..';
import { GetSpaceResult } from '..';
export interface ShareOptions {
selectedSpaceIds: string[];
@ -17,6 +17,7 @@ export interface ShareSavedObjectsToSpaceResponse {
[spaceId: string]: SavedObjectsImportResponse;
}
export interface SpaceTarget extends Omit<Space, 'disabledFeatures'> {
export interface SpaceTarget extends Omit<GetSpaceResult, 'disabledFeatures'> {
isActiveSpace: boolean;
isPartiallyAuthorized?: boolean;
}

View file

@ -8,10 +8,14 @@ import { skipWhile } from 'rxjs/operators';
import { HttpSetup } from 'src/core/public';
import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public';
import { Space } from '../../common/model/space';
import { GetSpacePurpose } from '../../common/model/types';
import { GetAllSpacesPurpose, GetSpaceResult } from '../../common/model/types';
import { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types';
type SavedObject = Pick<SavedObjectsManagementRecord, 'type' | 'id'>;
interface GetAllSpacesOptions {
purpose?: GetAllSpacesPurpose;
includeAuthorizedPurposes?: boolean;
}
export class SpacesManager {
private activeSpace$: BehaviorSubject<Space | null> = new BehaviorSubject<Space | null>(null);
@ -30,8 +34,10 @@ export class SpacesManager {
this.refreshActiveSpace();
}
public async getSpaces(purpose?: GetSpacePurpose): Promise<Space[]> {
return await this.http.get('/api/spaces/space', { query: { purpose } });
public async getSpaces(options: GetAllSpacesOptions = {}): Promise<GetSpaceResult[]> {
const { purpose, includeAuthorizedPurposes } = options;
const query = { purpose, include_authorized_purposes: includeAuthorizedPurposes };
return await this.http.get('/api/spaces/space', { query });
}
public async getSpace(id: string): Promise<Space> {

View file

@ -1,35 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#create authorization is null throws bad request when we are at the maximum number of spaces 1`] = `"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"`;
exports[`#create authorization.mode.useRbacForRequest returns false throws bad request when we're at the maximum number of spaces 1`] = `"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"`;
exports[`#create useRbacForRequest is true throws Boom.forbidden if the user isn't authorized at space 1`] = `"Unauthorized to create spaces"`;
exports[`#create useRbacForRequest is true throws bad request when we are at the maximum number of spaces 1`] = `"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"`;
exports[`#delete authorization is null throws bad request when the space is reserved 1`] = `"This Space cannot be deleted because it is reserved."`;
exports[`#delete authorization.mode.useRbacForRequest returns false throws bad request when the space is reserved 1`] = `"This Space cannot be deleted because it is reserved."`;
exports[`#delete authorization.mode.useRbacForRequest returns true throws Boom.forbidden if the user isn't authorized 1`] = `"Unauthorized to delete spaces"`;
exports[`#delete authorization.mode.useRbacForRequest returns true throws bad request if the user is authorized but the space is reserved 1`] = `"This Space cannot be deleted because it is reserved."`;
exports[`#get useRbacForRequest is true throws Boom.forbidden if the user isn't authorized at space 1`] = `"Unauthorized to get foo-space space"`;
exports[`#getAll authorization.mode.useRbacForRequest returns false throws Boom.badRequest when an invalid purpose is provided' 1`] = `"unsupported space purpose: invalid_purpose"`;
exports[`#getAll useRbacForRequest is true throws Boom.badRequest when an invalid purpose is provided 1`] = `"unsupported space purpose: invalid_purpose"`;
exports[`#getAll useRbacForRequest is true with purpose='any' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
exports[`#getAll useRbacForRequest is true with purpose='findSavedObjects' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
exports[`#getAll useRbacForRequest is true with purpose='shareSavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`;

View file

@ -11,31 +11,42 @@ import { isReservedSpace } from '../../../common/is_reserved_space';
import { Space } from '../../../common/model/space';
import { SpacesAuditLogger } from '../audit_logger';
import { ConfigType } from '../../config';
import { GetSpacePurpose } from '../../../common/model/types';
import { GetAllSpacesPurpose, GetSpaceResult } from '../../../common/model/types';
const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = [
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<
GetSpacePurpose,
GetAllSpacesPurpose,
(authorization: SecurityPluginSetup['authz']) => string[]
> = {
any: (authorization) => [authorization.actions.login],
copySavedObjectsIntoSpace: (authorization) => [
authorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'),
],
findSavedObjects: (authorization) => {
return [authorization.actions.savedObject.get('config', 'find')];
},
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,
@ -47,14 +58,17 @@ export class SpacesClient {
private readonly request: KibanaRequest
) {}
public async getAll(purpose: GetSpacePurpose = 'any'): Promise<Space[]> {
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 (this.useRbac()) {
const privilegeFactory = PURPOSE_PRIVILEGE_MAP[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',
@ -65,26 +79,62 @@ export class SpacesClient {
this.debugLogger(`SpacesClient.getAll(), using RBAC. Found ${saved_objects.length} spaces`);
const spaces = saved_objects.map(this.transformSavedObjectToSpace);
const spaces: GetSpaceResult[] = saved_objects.map(this.transformSavedObjectToSpace);
const spaceIds = spaces.map((space: Space) => space.id);
const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request);
const privilege = privilegeFactory(this.authorization!);
const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, {
kibana: privilege,
});
const authorized = privileges.kibana.filter((x) => x.authorized).map((x) => x.resource);
this.debugLogger(
`SpacesClient.getAll(), authorized for ${
authorized.length
} spaces, derived from ES privilege check: ${JSON.stringify(privileges)}`
// 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[]>
);
if (authorized.length === 0) {
// 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.`
);
@ -92,14 +142,12 @@ export class SpacesClient {
throw Boom.forbidden(); // Note: there is a catch for this in `SpacesSavedObjectsClient.find`; if we get rid of this error, remove that too
}
this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized as string[]);
const filteredSpaces: Space[] = spaces.filter((space: any) => authorized.includes(space.id));
const authorizedSpaceIds = authorizedSpaces.map((s) => s.id);
this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorizedSpaceIds);
this.debugLogger(
`SpacesClient.getAll(), using RBAC. returning spaces: ${filteredSpaces
.map((s) => s.id)
.join(',')}`
`SpacesClient.getAll(), using RBAC. returning spaces: ${authorizedSpaceIds.join(',')}`
);
return filteredSpaces;
return authorizedSpaces;
} else {
this.debugLogger(`SpacesClient.getAll(), NOT USING RBAC. querying all spaces`);

View file

@ -75,66 +75,54 @@ describe('GET /spaces/space', () => {
};
};
it(`returns all available spaces`, async () => {
const { routeHandler } = await setup();
[undefined, 'any', 'copySavedObjectsIntoSpace', 'shareSavedObjectsIntoSpace'].forEach(
(purpose) => {
describe(`with purpose='${purpose}'`, () => {
it(`returns expected result when not specifying include_authorized_purposes`, async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'get',
});
const request = httpServerMock.createKibanaRequest({ method: 'get', query: { purpose } });
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual(spaces);
});
expect(response.status).toEqual(200);
expect(response.payload).toEqual(spaces);
});
it(`returns expected result when specifying include_authorized_purposes=true`, async () => {
const { routeHandler } = await setup();
it(`returns all available spaces with the 'any' purpose`, async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'get',
query: { purpose, include_authorized_purposes: true },
});
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
const request = httpServerMock.createKibanaRequest({
query: {
purpose: 'any',
},
method: 'get',
});
if (purpose === undefined) {
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'`)
);
}
});
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
it(`returns expected result when specifying include_authorized_purposes=false`, async () => {
const { routeHandler } = await setup();
expect(response.status).toEqual(200);
expect(response.payload).toEqual(spaces);
});
const request = httpServerMock.createKibanaRequest({
method: 'get',
query: { purpose, include_authorized_purposes: false },
});
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
it(`returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
query: {
purpose: 'copySavedObjectsIntoSpace',
},
method: 'get',
});
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual(spaces);
});
it(`returns all available spaces with the 'shareSavedObjectsIntoSpace' purpose`, async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
query: {
purpose: 'shareSavedObjectsIntoSpace',
},
method: 'get',
});
const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual(spaces);
});
expect(response.status).toEqual(200);
expect(response.payload).toEqual(spaces);
});
});
}
);
it(`returns http/403 when the license is invalid`, async () => {
const { routeHandler } = await setup();

View file

@ -18,15 +18,18 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) {
path: '/api/spaces/space',
validate: {
query: schema.object({
purpose: schema.oneOf(
[
purpose: schema.maybe(
schema.oneOf([
schema.literal('any'),
schema.literal('copySavedObjectsIntoSpace'),
schema.literal('shareSavedObjectsIntoSpace'),
],
{
defaultValue: 'any',
}
])
),
include_authorized_purposes: schema.conditional(
schema.siblingRef('purpose'),
schema.string(),
schema.maybe(schema.literal(false)),
schema.maybe(schema.boolean())
),
}),
},
@ -34,18 +37,24 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) {
createLicensedRouteHandler(async (context, request, response) => {
log.debug(`Inside GET /api/spaces/space`);
const purpose = request.query.purpose;
const { purpose, include_authorized_purposes: includeAuthorizedPurposes } = request.query;
const spacesClient = await spacesService.scopedClient(request);
let spaces: Space[];
try {
log.debug(`Attempting to retrieve all spaces for ${purpose} purpose`);
spaces = await spacesClient.getAll(purpose);
log.debug(`Retrieved ${spaces.length} spaces for ${purpose} purpose`);
log.debug(
`Attempting to retrieve all spaces for ${purpose} purpose with includeAuthorizedPurposes=${includeAuthorizedPurposes}`
);
spaces = await spacesClient.getAll({ purpose, includeAuthorizedPurposes });
log.debug(
`Retrieved ${spaces.length} spaces for ${purpose} purpose with includeAuthorizedPurposes=${includeAuthorizedPurposes}`
);
} catch (error) {
log.debug(`Error retrieving spaces for ${purpose} purpose: ${error}`);
log.debug(
`Error retrieving spaces for ${purpose} purpose with includeAuthorizedPurposes=${includeAuthorizedPurposes}: ${error}`
);
return response.customError(wrapError(error));
}

View file

@ -227,7 +227,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
type: ['foo', 'bar'],
namespaces: ['ns-1', 'ns-2'],
});
expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects');
expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' });
});
test(`filters options.namespaces based on authorization`, async () => {
@ -258,7 +258,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
type: ['foo', 'bar'],
namespaces: ['ns-1'],
});
expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects');
expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' });
});
test(`translates options.namespace: ['*']`, async () => {
@ -289,7 +289,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
type: ['foo', 'bar'],
namespaces: ['ns-1', 'ns-2'],
});
expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects');
expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' });
});
});

View file

@ -170,7 +170,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
const spacesClient = await this.getSpacesClient;
try {
const availableSpaces = await spacesClient.getAll('findSavedObjects');
const availableSpaces = await spacesClient.getAll({ purpose: 'findSavedObjects' });
if (namespaces.includes(ALL_SPACES_ID)) {
namespaces = availableSpaces.map((space) => space.id);
} else {

View file

@ -17,6 +17,7 @@ interface GetAllTests {
exists: GetAllTest;
copySavedObjectsPurpose: GetAllTest;
shareSavedObjectsPurpose: GetAllTest;
includeAuthorizedPurposes: GetAllTest;
}
interface GetAllTestDefinition {
@ -25,29 +26,48 @@ interface GetAllTestDefinition {
tests: GetAllTests;
}
interface AuthorizedPurposes {
any: boolean;
copySavedObjectsIntoSpace: boolean;
findSavedObjects: boolean;
shareSavedObjectsIntoSpace: boolean;
}
const ALL_SPACE_RESULTS = [
{
id: 'default',
name: 'Default Space',
description: 'This is the default space',
_reserved: true,
disabledFeatures: [],
},
{
id: 'space_1',
name: 'Space 1',
description: 'This is the first test space',
disabledFeatures: [],
},
{
id: 'space_2',
name: 'Space 2',
description: 'This is the second test space',
disabledFeatures: [],
},
];
export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const createExpectResults = (...spaceIds: string[]) => (resp: { [key: string]: any }) => {
const expectedBody = [
{
id: 'default',
name: 'Default Space',
description: 'This is the default space',
_reserved: true,
disabledFeatures: [],
},
{
id: 'space_1',
name: 'Space 1',
description: 'This is the first test space',
disabledFeatures: [],
},
{
id: 'space_2',
name: 'Space 2',
description: 'This is the second test space',
disabledFeatures: [],
},
].filter((entry) => spaceIds.includes(entry.id));
const expectedBody = ALL_SPACE_RESULTS.filter((entry) => spaceIds.includes(entry.id));
expect(resp.body).to.eql(expectedBody);
};
const createExpectAllPurposesResults = (
authorizedPurposes: AuthorizedPurposes,
...spaceIds: string[]
) => (resp: { [key: string]: any }) => {
const expectedBody = ALL_SPACE_RESULTS.filter((entry) =>
spaceIds.includes(entry.id)
).map((x) => ({ ...x, authorizedPurposes }));
expect(resp.body).to.eql(expectedBody);
};
@ -72,15 +92,17 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
after(() => esArchiver.unload('saved_objects/spaces'));
getTestScenariosForSpace(spaceId).forEach(({ scenario, urlPrefix }) => {
it(`should return ${tests.exists.statusCode} ${scenario}`, async () => {
return supertest
.get(`${urlPrefix}/api/spaces/space`)
.auth(user.username, user.password)
.expect(tests.exists.statusCode)
.then(tests.exists.response);
describe('undefined purpose', () => {
it(`should return ${tests.exists.statusCode} ${scenario}`, async () => {
return supertest
.get(`${urlPrefix}/api/spaces/space`)
.auth(user.username, user.password)
.expect(tests.exists.statusCode)
.then(tests.exists.response);
});
});
describe('copySavedObjects purpose', () => {
describe('copySavedObjectsIntoSpace purpose', () => {
it(`should return ${tests.copySavedObjectsPurpose.statusCode} ${scenario}`, async () => {
return supertest
.get(`${urlPrefix}/api/spaces/space`)
@ -91,7 +113,7 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
});
});
describe('copySavedObjects purpose', () => {
describe('shareSavedObjectsIntoSpace purpose', () => {
it(`should return ${tests.shareSavedObjectsPurpose.statusCode} ${scenario}`, async () => {
return supertest
.get(`${urlPrefix}/api/spaces/space`)
@ -101,6 +123,17 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
.then(tests.copySavedObjectsPurpose.response);
});
});
describe('include_authorized_purposes=true', () => {
it(`should return ${tests.includeAuthorizedPurposes.statusCode} ${scenario}`, async () => {
return supertest
.get(`${urlPrefix}/api/spaces/space`)
.query({ include_authorized_purposes: true })
.auth(user.username, user.password)
.expect(tests.includeAuthorizedPurposes.statusCode)
.then(tests.includeAuthorizedPurposes.response);
});
});
});
});
};
@ -111,6 +144,7 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
return {
createExpectResults,
createExpectAllPurposesResults,
expectRbacForbidden,
getAllTest,
expectEmptyResult,

View file

@ -14,10 +14,26 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const { getAllTest, createExpectResults, expectRbacForbidden } = getAllTestSuiteFactory(
esArchiver,
supertestWithoutAuth
);
const {
getAllTest,
createExpectResults,
createExpectAllPurposesResults,
expectRbacForbidden,
} = getAllTestSuiteFactory(esArchiver, supertestWithoutAuth);
// these are used to determine expected results for tests where the `include_authorized_purposes` option is enabled
const authorizedAll = {
any: true,
copySavedObjectsIntoSpace: true,
findSavedObjects: true,
shareSavedObjectsIntoSpace: true,
};
const authorizedRead = {
any: true,
copySavedObjectsIntoSpace: false,
findSavedObjects: true,
shareSavedObjectsIntoSpace: false,
};
describe('get all', () => {
/* eslint-disable @typescript-eslint/naming-convention */
@ -92,6 +108,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 403,
response: expectRbacForbidden,
},
},
});
@ -111,6 +131,15 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('default', 'space_1', 'space_2'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(
authorizedAll,
'default',
'space_1',
'space_2'
),
},
},
});
@ -130,6 +159,15 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('default', 'space_1', 'space_2'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(
authorizedAll,
'default',
'space_1',
'space_2'
),
},
},
});
@ -149,6 +187,15 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('default', 'space_1', 'space_2'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(
authorizedAll,
'default',
'space_1',
'space_2'
),
},
},
});
@ -168,6 +215,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 403,
response: expectRbacForbidden,
},
},
});
@ -187,6 +238,15 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(
authorizedRead,
'default',
'space_1',
'space_2'
),
},
},
});
@ -206,6 +266,15 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(
authorizedRead,
'default',
'space_1',
'space_2'
),
},
},
});
@ -225,6 +294,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('space_1'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedAll, 'space_1'),
},
},
});
@ -244,6 +317,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedRead, 'space_1'),
},
},
});
@ -265,6 +342,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('default'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedAll, 'default'),
},
},
}
);
@ -287,6 +368,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedRead, 'default'),
},
},
}
);
@ -309,6 +394,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('default'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedAll, 'default'),
},
},
}
);
@ -331,6 +420,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedRead, 'default'),
},
},
}
);
@ -353,6 +446,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('space_1'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedAll, 'space_1'),
},
},
}
);
@ -375,6 +472,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectAllPurposesResults(authorizedRead, 'space_1'),
},
},
}
);
@ -395,6 +496,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 403,
response: expectRbacForbidden,
},
},
});
@ -414,6 +519,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 403,
response: expectRbacForbidden,
},
},
});
@ -433,6 +542,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 403,
response: expectRbacForbidden,
},
},
});
@ -452,6 +565,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 403,
response: expectRbacForbidden,
},
includeAuthorizedPurposes: {
statusCode: 403,
response: expectRbacForbidden,
},
},
});
});

View file

@ -42,6 +42,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) {
statusCode: 200,
response: createExpectResults('default', 'space_1', 'space_2'),
},
includeAuthorizedPurposes: {
statusCode: 200,
response: createExpectResults('default', 'space_1', 'space_2'),
},
},
});
});