diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx index 0fd03481eec1d..9d2457b38cfd8 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx @@ -23,6 +23,7 @@ import { get } from 'lodash'; import React, { ChangeEvent, Component, Fragment, HTMLProps } from 'react'; import { toastNotifications } from 'ui/notify'; import { Space } from '../../../../../../spaces/common/model/space'; +import { UserProfile } from '../../../../../../xpack_main/public/services/user_profile'; import { IndexPrivilege } from '../../../../../common/model/index_privilege'; import { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; import { Role } from '../../../../../common/model/role'; @@ -46,6 +47,7 @@ interface Props { notifier: any; spaces?: Space[]; spacesEnabled: boolean; + userProfile: UserProfile; } interface State { @@ -212,6 +214,7 @@ export class EditRolePage extends Component { kibanaAppPrivileges={this.props.kibanaAppPrivileges} spaces={this.props.spaces} spacesEnabled={this.props.spacesEnabled} + userProfile={this.props.userProfile} editable={!isReservedRole(this.state.role)} role={this.state.role} onChange={this.onRoleChange} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap index 85f0acaf92a40..3a8d878dd9419 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap @@ -42,6 +42,11 @@ exports[` renders without crashing 1`] = ` }, ] } + userProfile={ + Object { + "hasCapability": [Function], + } + } validator={ RoleValidator { "inProgressSpacePrivileges": Array [], diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap index 07e626b541350..eb59b66483292 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap @@ -217,9 +217,39 @@ exports[` renders without crashing 1`] = ` }, ] } + userProfile={ + Object { + "hasCapability": [Function], + } + } /> `; + +exports[` with user profile disabling "manageSpaces" renders a warning message instead of the privilege form 1`] = ` + + Insufficient Privileges +

+ } +> +

+ You are not authorized to view all available spaces. +

+

+ Please ensure your account has all privileges granted by the + + + kibana_user + + role, and try again. +

+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx index 674b44ef3dbf6..aafe9d273c5c4 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx @@ -35,6 +35,9 @@ const buildProps = (customProps = {}) => { name: 'Marketing', }, ], + userProfile: { + hasCapability: () => true, + }, kibanaAppPrivileges: [ { name: 'all', diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx index 63c595314e24d..e8d53770270db 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx @@ -17,6 +17,7 @@ import { PrivilegeSpaceTable } from './privilege_space_table'; import { Space } from '../../../../../../../../spaces/common/model/space'; import { ManageSpacesButton } from '../../../../../../../../spaces/public/components'; +import { UserProfile } from '../../../../../../../../xpack_main/public/services/user_profile'; import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; import { Role } from '../../../../../../../common/model/role'; import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; @@ -25,6 +26,7 @@ import './impacted_spaces_flyout.less'; interface Props { role: Role; spaces: Space[]; + userProfile: UserProfile; } interface State { @@ -118,7 +120,7 @@ export class ImpactedSpacesFlyout extends Component { {/* TODO: Hide footer if button is not available */} - + ); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx index 82a1133f7aaa2..07dd40c5de8a5 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx @@ -38,6 +38,7 @@ const buildProps = (customProps = {}) => { name: 'Marketing', }, ], + userProfile: { hasCapability: () => true }, editable: true, kibanaAppPrivileges: ['all' as KibanaPrivilege], onChange: jest.fn(), diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx index 1ff7b62f715a7..80d848b5893d5 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx @@ -6,6 +6,7 @@ import React, { Component } from 'react'; import { Space } from '../../../../../../../../spaces/common/model/space'; +import { UserProfile } from '../../../../../../../../xpack_main/public/services/user_profile'; import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; import { Role } from '../../../../../../../common/model/role'; import { RoleValidator } from '../../../lib/validate_role'; @@ -17,6 +18,7 @@ interface Props { role: Role; spacesEnabled: boolean; spaces?: Space[]; + userProfile: UserProfile; editable: boolean; kibanaAppPrivileges: KibanaPrivilege[]; onChange: (role: Role) => void; @@ -38,6 +40,7 @@ export class KibanaPrivileges extends Component { role, spacesEnabled, spaces = [], + userProfile, onChange, editable, validator, @@ -49,6 +52,7 @@ export class KibanaPrivileges extends Component { kibanaAppPrivileges={kibanaAppPrivileges} role={role} spaces={spaces} + userProfile={userProfile} onChange={onChange} editable={editable} validator={validator} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx index bcc5963743610..b386fcafc614c 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx @@ -37,6 +37,7 @@ const buildProps = (customProps: any = {}) => { name: 'Marketing', }, ], + userProfile: { hasCapability: () => true }, editable: true, kibanaAppPrivileges: ['all', 'read'], onChange: jest.fn(), @@ -229,4 +230,22 @@ describe('', () => { expect(addPrivilegeButton).toHaveLength(1); }); }); + + describe('with user profile disabling "manageSpaces"', () => { + it('renders a warning message instead of the privilege form', () => { + const props = buildProps({ + userProfile: { + hasCapability: (capability: string) => { + if (capability === 'manageSpaces') { + return false; + } + throw new Error(`unexpected call to hasCapability: ${capability}`); + }, + }, + }); + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx index 2801862cabf94..dcdaf055cae8d 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx @@ -6,6 +6,7 @@ import { EuiButton, + EuiCallOut, // @ts-ignore EuiDescribedFormGroup, EuiFlexGroup, @@ -17,6 +18,7 @@ import { } from '@elastic/eui'; import React, { Component, Fragment } from 'react'; import { Space } from '../../../../../../../../spaces/common/model/space'; +import { UserProfile } from '../../../../../../../../xpack_main/public/services/user_profile'; import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; import { Role } from '../../../../../../../common/model/role'; import { isReservedRole } from '../../../../../../lib/role'; @@ -37,6 +39,7 @@ interface Props { onChange: (role: Role) => void; editable: boolean; validator: RoleValidator; + userProfile: UserProfile; } interface PrivilegeForm { @@ -70,7 +73,19 @@ export class SpaceAwarePrivilegeForm extends Component { } public render() { - const { kibanaAppPrivileges, role } = this.props; + const { kibanaAppPrivileges, role, userProfile } = this.props; + + if (!userProfile.hasCapability('manageSpaces')) { + return ( + Insufficient Privileges

} iconType="alert" color="danger"> +

You are not authorized to view all available spaces.

+

+ Please ensure your account has all privileges granted by the{' '} + kibana_user role, and try again. +

+
+ ); + } const assignedPrivileges = role.kibana; @@ -190,7 +205,11 @@ export class SpaceAwarePrivilegeForm extends Component { )} - + diff --git a/x-pack/plugins/security/public/views/management/edit_role/index.js b/x-pack/plugins/security/public/views/management/edit_role/index.js index 5c744c908824c..0d71749d0f121 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/index.js +++ b/x-pack/plugins/security/public/views/management/edit_role/index.js @@ -19,6 +19,7 @@ import 'plugins/security/services/shield_indices'; import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns'; import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; +import { UserProfileProvider } from 'plugins/xpack_main/services/user_profile'; import { SpacesManager } from 'plugins/spaces/lib'; import { checkLicenseError } from 'plugins/security/lib/check_license_error'; import { EDIT_ROLES_PATH, ROLES_PATH } from '../management_urls'; @@ -92,6 +93,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { const role = $route.current.locals.role; const xpackInfo = Private(XPackInfoProvider); + const userProfile = Private(UserProfileProvider); const allowDocumentLevelSecurity = xpackInfo.get('features.security.allowRoleDocumentLevelSecurity'); const allowFieldLevelSecurity = xpackInfo.get('features.security.allowRoleFieldLevelSecurity'); const rbacApplication = chrome.getInjected('rbacApplication'); @@ -137,6 +139,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { notifier={Notifier} spaces={spaces} spacesEnabled={enableSpaceAwarePrivileges} + userProfile={userProfile} />, domNode); // unmount react on controller destroy diff --git a/x-pack/plugins/spaces/index.js b/x-pack/plugins/spaces/index.js index b7cfeb94f958e..7164854a11e2b 100644 --- a/x-pack/plugins/spaces/index.js +++ b/x-pack/plugins/spaces/index.js @@ -19,6 +19,7 @@ import { wrapError } from './server/lib/errors'; import mappings from './mappings.json'; import { spacesSavedObjectsClientWrapperFactory } from './server/lib/saved_objects_client/saved_objects_client_wrapper_factory'; import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; +import { registerUserProfileCapabilityFactory } from '../xpack_main/server/lib/user_profile_registry'; import { SpacesClient } from './server/lib/spaces_client'; import { SpacesAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; @@ -124,6 +125,14 @@ export const spaces = (kibana) => new kibana.Plugin({ initSpacesRequestInterceptors(server); + registerUserProfileCapabilityFactory(async (request) => { + const spacesClient = server.plugins.spaces.spacesClient.getScopedClient(request); + + return { + manageSpaces: await spacesClient.canEnumerateSpaces(), + }; + }); + // Register a function with server to manage the collection of usage stats server.usage.collectorSet.register(getSpacesUsageCollector(server)); } diff --git a/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx b/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx index 81113fd4cde87..eb70629255ed0 100644 --- a/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx +++ b/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx @@ -6,16 +6,22 @@ import { EuiButton } from '@elastic/eui'; import React, { Component, CSSProperties } from 'react'; +import { UserProfile } from '../../../xpack_main/public/services/user_profile'; import { MANAGE_SPACES_URL } from '../lib/constants'; interface Props { isDisabled?: boolean; size?: 's' | 'l'; style?: CSSProperties; + userProfile: UserProfile; } export class ManageSpacesButton extends Component { public render() { + if (!this.props.userProfile.hasCapability('manageSpaces')) { + return null; + } + return ( ( + Permission denied} + body={ +

You do not have permission to manage spaces.

+ } + /> +); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js index 7524fa11dfd8d..0ff739e127656 100644 --- a/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js +++ b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js @@ -34,6 +34,7 @@ import { CustomizeSpaceAvatar } from './customize_space_avatar'; import { isReservedSpace } from '../../../../common'; import { ReservedSpaceBadge } from './reserved_space_badge'; import { SpaceValidator } from '../lib/validate_space'; +import { UnauthorizedPrompt } from '../components/unauthorized_prompt'; export class ManageSpacePage extends Component { state = { @@ -99,6 +100,14 @@ export class ManageSpacePage extends Component { } getForm = () => { + const { + userProfile + } = this.props; + + if (!userProfile.hasCapability('manageSpaces')) { + return ; + } + const { name = '', description = '', @@ -336,4 +345,5 @@ ManageSpacePage.propTypes = { space: PropTypes.string, spacesManager: PropTypes.object, spacesNavState: PropTypes.object.isRequired, + userProfile: PropTypes.object.isRequired, }; diff --git a/x-pack/plugins/spaces/public/views/management/page_routes.js b/x-pack/plugins/spaces/public/views/management/page_routes.js index 07f97439184fe..17bb29ac6282a 100644 --- a/x-pack/plugins/spaces/public/views/management/page_routes.js +++ b/x-pack/plugins/spaces/public/views/management/page_routes.js @@ -7,6 +7,7 @@ import 'ui/autoload/styles'; import 'plugins/spaces/views/management/manage_spaces.less'; import template from 'plugins/spaces/views/management/template.html'; +import { UserProfileProvider } from 'plugins/xpack_main/services/user_profile'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; @@ -20,7 +21,9 @@ const reactRootNodeId = 'manageSpacesReactRoot'; routes.when('/management/spaces/list', { template, - controller: function ($scope, $http, chrome, spacesNavState, spaceSelectorURL) { + controller: function ($scope, $http, chrome, Private, spacesNavState, spaceSelectorURL) { + const userProfile = Private(UserProfileProvider); + $scope.$$postDigest(() => { const domNode = document.getElementById(reactRootNodeId); @@ -29,6 +32,7 @@ routes.when('/management/spaces/list', { render(, domNode); // unmount react on controller destroy @@ -41,7 +45,9 @@ routes.when('/management/spaces/list', { routes.when('/management/spaces/create', { template, - controller: function ($scope, $http, chrome, spacesNavState, spaceSelectorURL) { + controller: function ($scope, $http, chrome, Private, spacesNavState, spaceSelectorURL) { + const userProfile = Private(UserProfileProvider); + $scope.$$postDigest(() => { const domNode = document.getElementById(reactRootNodeId); @@ -50,6 +56,7 @@ routes.when('/management/spaces/create', { render(, domNode); // unmount react on controller destroy @@ -66,7 +73,9 @@ routes.when('/management/spaces/edit', { routes.when('/management/spaces/edit/:space', { template, - controller: function ($scope, $http, $route, chrome, spacesNavState, spaceSelectorURL) { + controller: function ($scope, $http, $route, chrome, Private, spacesNavState, spaceSelectorURL) { + const userProfile = Private(UserProfileProvider); + $scope.$$postDigest(() => { const domNode = document.getElementById(reactRootNodeId); @@ -81,6 +90,7 @@ routes.when('/management/spaces/edit/:space', { chrome={chrome} spacesManager={spacesManager} spacesNavState={spacesNavState} + userProfile={userProfile} />, domNode); // unmount react on controller destroy diff --git a/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx index 83a7d4149a25d..f627159718452 100644 --- a/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { EuiButton, @@ -22,15 +22,18 @@ import { import { toastNotifications } from 'ui/notify'; +import { UserProfile } from '../../../../../xpack_main/public/services/user_profile'; import { isReservedSpace } from '../../../../common'; import { Space } from '../../../../common/model/space'; import { SpaceAvatar } from '../../../components'; import { SpacesManager } from '../../../lib/spaces_manager'; import { ConfirmDeleteModal } from '../components/confirm_delete_modal'; +import { UnauthorizedPrompt } from '../components/unauthorized_prompt'; interface Props { spacesManager: SpacesManager; spacesNavState: any; + userProfile: UserProfile; } interface State { @@ -61,38 +64,48 @@ export class SpacesGridPage extends Component { return ( - - - - -

Spaces

-
-
- {this.getPrimaryActionButton()} -
- - - -
+ {this.getPageContent()}
{this.getConfirmDeleteModal()}
); } + public getPageContent() { + if (!this.props.userProfile.hasCapability('manageSpaces')) { + return ; + } + + return ( + + + + +

Spaces

+
+
+ {this.getPrimaryActionButton()} +
+ + + +
+ ); + } + public getPrimaryActionButton() { return ( - + `; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap index b46ae0481840a..ae120ec8fbab2 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap +++ b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap @@ -26,6 +26,11 @@ exports[`SpacesDescription renders without crashing 1`] = ` "width": "100%", } } + userProfile={ + Object { + "hasCapability": [Function], + } + } /> diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx index 0308b8b5a4d21..9d8dd36999c75 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx @@ -10,6 +10,8 @@ import { SpacesDescription } from './spaces_description'; describe('SpacesDescription', () => { it('renders without crashing', () => { - expect(shallow()).toMatchSnapshot(); + expect( + shallow( true }} />) + ).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx index 127595c930ee4..8d033996f6a8d 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx @@ -6,11 +6,16 @@ import { EuiContextMenuPanel, EuiText } from '@elastic/eui'; import React, { SFC } from 'react'; +import { UserProfile } from '../../../../../xpack_main/public/services/user_profile'; import { ManageSpacesButton } from '../../../components'; import { SPACES_FEATURE_DESCRIPTION } from '../../../lib/constants'; import './spaces_description.less'; -export const SpacesDescription: SFC = () => { +interface Props { + userProfile: UserProfile; +} + +export const SpacesDescription: SFC = (props: Props) => { const panelProps = { className: 'spacesDescription', title: 'Spaces', @@ -22,7 +27,7 @@ export const SpacesDescription: SFC = () => {

{SPACES_FEATURE_DESCRIPTION}

- +
); diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx index 2376914ec1b11..7827e26555230 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx @@ -6,6 +6,7 @@ import { EuiContextMenuItem, EuiContextMenuPanel, EuiFieldSearch, EuiText } from '@elastic/eui'; import React, { Component } from 'react'; +import { UserProfile } from '../../../../../xpack_main/public/services/user_profile'; import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../common/constants'; import { Space } from '../../../../common/model/space'; import { ManageSpacesButton, SpaceAvatar } from '../../../components'; @@ -14,6 +15,7 @@ import './spaces_menu.less'; interface Props { spaces: Space[]; onSelectSpace: (space: Space) => void; + userProfile: UserProfile; } interface State { @@ -133,7 +135,11 @@ export class SpacesMenu extends Component { private renderManageButton = () => { return (
- +
); }; diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js index c1599ed1a6bc5..657194a09a5a8 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js @@ -10,6 +10,7 @@ import { uiModules } from 'ui/modules'; import { SpacesManager } from 'plugins/spaces/lib/spaces_manager'; import template from 'plugins/spaces/views/nav_control/nav_control.html'; import 'plugins/spaces/views/nav_control/nav_control.less'; +import { UserProfileProvider } from 'plugins/xpack_main/services/user_profile'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; @@ -25,7 +26,9 @@ const module = uiModules.get('spaces_nav', ['kibana']); let spacesManager; -module.controller('spacesNavController', ($scope, $http, chrome, activeSpace, spaceSelectorURL) => { +module.controller('spacesNavController', ($scope, $http, chrome, Private, activeSpace, spaceSelectorURL) => { + const userProfile = Private(UserProfileProvider); + const domNode = document.getElementById(`spacesNavReactRoot`); spacesManager = new SpacesManager($http, chrome, spaceSelectorURL); @@ -34,7 +37,7 @@ module.controller('spacesNavController', ($scope, $http, chrome, activeSpace, sp $scope.$parent.$watch('isVisible', function (isVisible) { if (isVisible && !mounted) { - render(, domNode); + render(, domNode); mounted = true; } }); diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js index 0c35f175d930d..9252055e33957 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js @@ -41,7 +41,11 @@ describe('NavControlPopover', () => { const spacesManager = new SpacesManager(createMockHttpAgent(), mockChrome); - const wrapper = shallow(); + const wrapper = shallow( true }} + />); expect(wrapper).toMatchSnapshot(); }); @@ -55,7 +59,11 @@ describe('NavControlPopover', () => { const spacesManager = new SpacesManager(mockAgent, mockChrome); - const wrapper = mount(); + const wrapper = mount( true }} + />); return new Promise((resolve) => { setTimeout(() => { diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx index 6bd643de0f3f1..bdf45b7c61bac 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx @@ -6,6 +6,7 @@ import { EuiAvatar, EuiPopover } from '@elastic/eui'; import React, { Component } from 'react'; +import { UserProfile } from '../../../../xpack_main/public/services/user_profile'; import { Space } from '../../../common/model/space'; import { SpaceAvatar } from '../../components'; import { SpacesManager } from '../../lib/spaces_manager'; @@ -19,6 +20,7 @@ interface Props { error: string; space: Space; }; + userProfile: UserProfile; } interface State { @@ -57,9 +59,15 @@ export class NavControlPopover extends Component { let element: React.ReactNode; if (this.state.spaces.length < 2) { - element = ; + element = ; } else { - element = ; + element = ( + + ); } return ( diff --git a/x-pack/plugins/spaces/server/lib/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client.test.ts index 6aaed0bb9506c..e272d7d745498 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client.test.ts @@ -241,6 +241,109 @@ describe('#getAll', () => { }); }); +describe('#canEnumerateSpaces', () => { + describe(`authorization is null`, () => { + test(`returns true`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const request = Symbol(); + + const client = new SpacesClient(mockAuditLogger as any, authorization, null, null, request); + + const canEnumerateSpaces = await client.canEnumerateSpaces(); + expect(canEnumerateSpaces).toEqual(true); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest is false`, () => { + test(`returns true`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + const canEnumerateSpaces = await client.canEnumerateSpaces(); + + expect(canEnumerateSpaces).toEqual(true); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`returns false if user is not authorized to enumerate spaces`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: false, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + + const canEnumerateSpaces = await client.canEnumerateSpaces(); + expect(canEnumerateSpaces).toEqual(false); + + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`returns true if user is authorized to enumerate spaces`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: true, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + + const canEnumerateSpaces = await client.canEnumerateSpaces(); + expect(canEnumerateSpaces).toEqual(true); + + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); +}); + describe('#get', () => { const savedObject = { id: 'foo', diff --git a/x-pack/plugins/spaces/server/lib/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client.ts index 2942b5a019fcb..96bfd995ce56b 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client.ts @@ -30,6 +30,19 @@ export class SpacesClient { this.request = request; } + public async canEnumerateSpaces(): Promise { + if (this.useRbac()) { + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { hasAllRequested } = await checkPrivileges.globally( + this.authorization.actions.manageSpaces + ); + return hasAllRequested; + } + + // If not RBAC, then we are legacy, and all legacy users can enumerate all spaces + return true; + } + public async getAll(): Promise<[Space]> { if (this.useRbac()) { const { saved_objects } = await this.internalSavedObjectRepository.find({ diff --git a/x-pack/plugins/xpack_main/index.js b/x-pack/plugins/xpack_main/index.js index 6e6ed25893ced..231bed0fe0348 100644 --- a/x-pack/plugins/xpack_main/index.js +++ b/x-pack/plugins/xpack_main/index.js @@ -88,6 +88,7 @@ export const xpackMain = (kibana) => { telemetryUrl: config.get('xpack.xpack_main.telemetry.url'), telemetryEnabled: isTelemetryEnabled(config), telemetryOptedIn: null, + userProfile: {}, }; }, hacks: [ diff --git a/x-pack/plugins/xpack_main/public/services/user_profile.test.ts b/x-pack/plugins/xpack_main/public/services/user_profile.test.ts new file mode 100644 index 0000000000000..45507ab604284 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/services/user_profile.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { UserProfileProvider } from './user_profile'; + +describe('UserProfile', () => { + it('should return true when the specified capability is enabled', () => { + const capabilities = { + test1: true, + test2: false, + }; + + const userProfile = UserProfileProvider(capabilities); + + expect(userProfile.hasCapability('test1')).toEqual(true); + }); + + it('should return false when the specified capability is disabled', () => { + const capabilities = { + test1: true, + test2: false, + }; + + const userProfile = UserProfileProvider(capabilities); + + expect(userProfile.hasCapability('test2')).toEqual(false); + }); + + it('should return the default value when the specified capability is not defined', () => { + const capabilities = { + test1: true, + test2: false, + }; + + const userProfile = UserProfileProvider(capabilities); + + expect(userProfile.hasCapability('test3')).toEqual(true); + expect(userProfile.hasCapability('test3', false)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/xpack_main/public/services/user_profile.ts b/x-pack/plugins/xpack_main/public/services/user_profile.ts new file mode 100644 index 0000000000000..09b257aa80e3f --- /dev/null +++ b/x-pack/plugins/xpack_main/public/services/user_profile.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +interface Capabilities { + [capability: string]: boolean; +} + +export interface UserProfile { + hasCapability: (capability: string) => boolean; +} + +export function UserProfileProvider(userProfile: Capabilities) { + class UserProfileClass implements UserProfile { + private capabilities: Capabilities; + + constructor(profileData: Capabilities = {}) { + this.capabilities = { + ...profileData, + }; + } + + public hasCapability(capability: string, defaultValue: boolean = true): boolean { + return capability in this.capabilities ? this.capabilities[capability] : defaultValue; + } + } + + return new UserProfileClass(userProfile); +} diff --git a/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js b/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js index 990e4e1a7d53a..b8362c6549e16 100644 --- a/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js +++ b/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js @@ -5,13 +5,15 @@ */ import { getTelemetryOptIn } from "./get_telemetry_opt_in"; +import { buildUserProfile } from './user_profile_registry'; export async function replaceInjectedVars(originalInjectedVars, request, server) { const xpackInfo = server.plugins.xpack_main.info; const withXpackInfo = async () => ({ ...originalInjectedVars, telemetryOptedIn: await getTelemetryOptIn(request), - xpackInitialInfo: xpackInfo.isAvailable() ? xpackInfo.toJSON() : undefined + xpackInitialInfo: xpackInfo.isAvailable() ? xpackInfo.toJSON() : undefined, + userProfile: await buildUserProfile(request), }); // security feature is disabled diff --git a/x-pack/plugins/xpack_main/server/lib/user_profile_registry.test.ts b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.test.ts new file mode 100644 index 0000000000000..22a0b58b60c2a --- /dev/null +++ b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { + buildUserProfile, + registerUserProfileCapabilityFactory, + removeAllFactories, +} from './user_profile_registry'; + +describe('UserProfileRegistry', () => { + beforeEach(() => removeAllFactories()); + + it('should produce an empty user profile', async () => { + expect(await buildUserProfile(null)).toEqual({}); + }); + + it('should accumulate the results of all registered factories', async () => { + registerUserProfileCapabilityFactory(async () => ({ + foo: true, + bar: false, + })); + + registerUserProfileCapabilityFactory(async () => ({ + anotherCapability: true, + })); + + expect(await buildUserProfile(null)).toEqual({ + foo: true, + bar: false, + anotherCapability: true, + }); + }); +}); diff --git a/x-pack/plugins/xpack_main/server/lib/user_profile_registry.ts b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.ts new file mode 100644 index 0000000000000..417341165fde4 --- /dev/null +++ b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type CapabilityFactory = (request: any) => Promise<{ [capability: string]: boolean }>; + +let factories: CapabilityFactory[] = []; + +export function removeAllFactories() { + factories = []; +} + +export function registerUserProfileCapabilityFactory(factory: CapabilityFactory) { + factories.push(factory); +} + +export async function buildUserProfile(request: any) { + const factoryPromises = factories.map(async factory => ({ + ...(await factory(request)), + })); + + const factoryResults = await Promise.all(factoryPromises); + + return factoryResults.reduce((acc, capabilities) => { + return { + ...acc, + ...capabilities, + }; + }, {}); +}