diff --git a/x-pack/index.js b/x-pack/index.js index 2b9c71a1831890..a0c1f30b8d6e97 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -31,6 +31,7 @@ module.exports = function (kibana) { graph(kibana), monitoring(kibana), reporting(kibana), + spaces(kibana), security(kibana), searchprofiler(kibana), ml(kibana), @@ -44,7 +45,6 @@ module.exports = function (kibana) { cloud(kibana), indexManagement(kibana), consoleExtensions(kibana), - spaces(kibana), notifications(kibana), kueryAutocomplete(kibana) ]; diff --git a/x-pack/plugins/security/common/model/index_privilege.ts b/x-pack/plugins/security/common/model/index_privilege.ts new file mode 100644 index 00000000000000..560e8df5e126b2 --- /dev/null +++ b/x-pack/plugins/security/common/model/index_privilege.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IndexPrivilege { + names: string[]; + privileges: string[]; + field_security?: { + grant?: string[]; + }; + query?: string; +} diff --git a/x-pack/plugins/security/public/objects/role.js b/x-pack/plugins/security/common/model/kibana_application_privilege.ts similarity index 64% rename from x-pack/plugins/security/public/objects/role.js rename to x-pack/plugins/security/common/model/kibana_application_privilege.ts index 98448db207cea8..54350ec2abcef0 100644 --- a/x-pack/plugins/security/public/objects/role.js +++ b/x-pack/plugins/security/common/model/kibana_application_privilege.ts @@ -4,10 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export class Role { - name = null; - cluster = []; - indices = []; - run_as = []; //eslint-disable-line camelcase - applications = []; +import { KibanaPrivilege } from './kibana_privilege'; + +export interface KibanaApplicationPrivilege { + name: KibanaPrivilege; } diff --git a/x-pack/plugins/security/common/model/kibana_privilege.ts b/x-pack/plugins/security/common/model/kibana_privilege.ts new file mode 100644 index 00000000000000..834e62570fe263 --- /dev/null +++ b/x-pack/plugins/security/common/model/kibana_privilege.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type KibanaPrivilege = 'none' | 'read' | 'all'; diff --git a/x-pack/plugins/security/common/model/role.ts b/x-pack/plugins/security/common/model/role.ts new file mode 100644 index 00000000000000..5b1094c8c3a0ab --- /dev/null +++ b/x-pack/plugins/security/common/model/role.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPrivilege } from './index_privilege'; +import { KibanaPrivilege } from './kibana_privilege'; + +export interface Role { + name: string; + elasticsearch: { + cluster: string[]; + indices: IndexPrivilege[]; + run_as: string[]; + }; + kibana: { + global: KibanaPrivilege[]; + space: { + [spaceId: string]: KibanaPrivilege[]; + }; + }; + metadata?: { + [anyKey: string]: any; + }; + transient_metadata?: { + [anyKey: string]: any; + }; +} diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 32be38c740b0f1..68601a5ef07bab 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -78,6 +78,7 @@ export const security = (kibana) => new kibana.Plugin({ return { secureCookies: config.get('xpack.security.secureCookies'), sessionTimeout: config.get('xpack.security.sessionTimeout'), + enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), }; } }, diff --git a/x-pack/plugins/security/public/lib/role.test.js b/x-pack/plugins/security/public/lib/role.test.ts similarity index 87% rename from x-pack/plugins/security/public/lib/role.test.js rename to x-pack/plugins/security/public/lib/role.test.ts index 4c6e9fb896dcf1..c86b250e034f6e 100644 --- a/x-pack/plugins/security/public/lib/role.test.js +++ b/x-pack/plugins/security/public/lib/role.test.ts @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isRoleEnabled, isReservedRole } from './role'; +import { isReservedRole, isRoleEnabled } from './role'; describe('role', () => { describe('isRoleEnabled', () => { test('should return false if role is explicitly not enabled', () => { const testRole = { transient_metadata: { - enabled: false - } + enabled: false, + }, }; expect(isRoleEnabled(testRole)).toBe(false); }); @@ -20,8 +20,8 @@ describe('role', () => { test('should return true if role is explicitly enabled', () => { const testRole = { transient_metadata: { - enabled: true - } + enabled: true, + }, }; expect(isRoleEnabled(testRole)).toBe(true); }); @@ -36,8 +36,8 @@ describe('role', () => { test('should return false if role is explicitly not reserved', () => { const testRole = { metadata: { - _reserved: false - } + _reserved: false, + }, }; expect(isReservedRole(testRole)).toBe(false); }); @@ -45,8 +45,8 @@ describe('role', () => { test('should return true if role is explicitly reserved', () => { const testRole = { metadata: { - _reserved: true - } + _reserved: true, + }, }; expect(isReservedRole(testRole)).toBe(true); }); diff --git a/x-pack/plugins/security/public/lib/role.js b/x-pack/plugins/security/public/lib/role.ts similarity index 81% rename from x-pack/plugins/security/public/lib/role.js rename to x-pack/plugins/security/public/lib/role.ts index 96057aa55f5954..d6221f7aecb4c3 100644 --- a/x-pack/plugins/security/public/lib/role.js +++ b/x-pack/plugins/security/public/lib/role.ts @@ -5,6 +5,7 @@ */ import { get } from 'lodash'; +import { Role } from '../../common/model/role'; /** * Returns whether given role is enabled or not @@ -12,7 +13,7 @@ import { get } from 'lodash'; * @param role Object Role JSON, as returned by roles API * @return Boolean true if role is enabled; false otherwise */ -export function isRoleEnabled(role) { +export function isRoleEnabled(role: Partial) { return get(role, 'transient_metadata.enabled', true); } @@ -21,6 +22,6 @@ export function isRoleEnabled(role) { * * @param {role} the Role as returned by roles API */ -export function isReservedRole(role) { +export function isReservedRole(role: Partial) { return get(role, 'metadata._reserved', false); } diff --git a/x-pack/plugins/security/public/objects/index.js b/x-pack/plugins/security/public/objects/index.ts similarity index 100% rename from x-pack/plugins/security/public/objects/index.js rename to x-pack/plugins/security/public/objects/index.ts diff --git a/x-pack/plugins/security/public/objects/lib/get_fields.js b/x-pack/plugins/security/public/objects/lib/get_fields.ts similarity index 67% rename from x-pack/plugins/security/public/objects/lib/get_fields.js rename to x-pack/plugins/security/public/objects/lib/get_fields.ts index a80f6bfe8eed1d..e0998eb8b8f6be 100644 --- a/x-pack/plugins/security/public/objects/lib/get_fields.js +++ b/x-pack/plugins/security/public/objects/lib/get_fields.ts @@ -3,12 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { IHttpResponse } from 'angular'; import chrome from 'ui/chrome'; const apiBase = chrome.addBasePath(`/api/security/v1/fields`); -export async function getFields($http, query) { +export async function getFields($http: any, query: string): Promise { return await $http .get(`${apiBase}/${query}`) - .then(response => response.data || []); + .then((response: IHttpResponse) => response.data || []); } diff --git a/x-pack/plugins/security/public/objects/lib/roles.js b/x-pack/plugins/security/public/objects/lib/roles.ts similarity index 77% rename from x-pack/plugins/security/public/objects/lib/roles.js rename to x-pack/plugins/security/public/objects/lib/roles.ts index 7f0bbdbb30dc5f..2551d7eabc4e71 100644 --- a/x-pack/plugins/security/public/objects/lib/roles.js +++ b/x-pack/plugins/security/public/objects/lib/roles.ts @@ -3,16 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; import { omit } from 'lodash'; +import chrome from 'ui/chrome'; +import { Role } from '../../../common/model/role'; const apiBase = chrome.addBasePath(`/api/security/role`); -export async function saveRole($http, role) { +export async function saveRole($http: any, role: Role) { const data = omit(role, 'name', 'transient_metadata', '_unrecognized_applications'); return await $http.put(`${apiBase}/${role.name}`, data); } -export async function deleteRole($http, name) { +export async function deleteRole($http: any, name: string) { return await $http.delete(`${apiBase}/${name}`); } diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.tsx.snap similarity index 98% rename from x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.js.snap rename to x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.tsx.snap index d946357354fe2c..75c5d914936452 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.js.snap +++ b/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.tsx.snap @@ -40,7 +40,6 @@ exports[`it renders without blowing up 1`] = ` hide diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.tsx similarity index 79% rename from x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.tsx index ec0182101653ac..86f1e73b78e1b9 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.tsx @@ -4,17 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiLink } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; import React from 'react'; -import { shallow, mount } from 'enzyme'; import { CollapsiblePanel } from './collapsible_panel'; -import { EuiLink } from '@elastic/eui'; test('it renders without blowing up', () => { const wrapper = shallow( - +

child

); @@ -24,10 +21,7 @@ test('it renders without blowing up', () => { test('it renders children by default', () => { const wrapper = mount( - +

child 1

child 2

@@ -39,10 +33,7 @@ test('it renders children by default', () => { test('it hides children when the "hide" link is clicked', () => { const wrapper = mount( - +

child 1

child 2

diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.js b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.tsx similarity index 60% rename from x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.js rename to x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.tsx index f2bac7a02b99c8..a58042fda96978 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.tsx @@ -4,30 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import './collapsible_panel.less'; import { - EuiPanel, - EuiLink, - EuiIcon, EuiFlexGroup, EuiFlexItem, - EuiTitle, + EuiIcon, + EuiLink, + EuiPanel, EuiSpacer, + EuiTitle, } from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import './collapsible_panel.less'; -export class CollapsiblePanel extends Component { - static propTypes = { - iconType: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - } +interface Props { + iconType: string | any; + title: string; +} - state = { - collapsed: false - } +interface State { + collapsed: boolean; +} - render() { +export class CollapsiblePanel extends Component { + public state = { + collapsed: false, + }; + + public render() { return ( {this.getTitle()} @@ -36,24 +39,30 @@ export class CollapsiblePanel extends Component { ); } - getTitle = () => { + public getTitle = () => { return ( + // @ts-ignore

- {this.props.title} + {' '} + {this.props.title}

- {this.state.collapsed ? 'show' : 'hide'} + {this.state.collapsed ? 'show' : 'hide'}
); }; - getForm = () => { + public getForm = () => { if (this.state.collapsed) { return null; } @@ -64,11 +73,11 @@ export class CollapsiblePanel extends Component { {this.props.children} ); - } + }; - toggleCollapsed = () => { + public toggleCollapsed = () => { this.setState({ - collapsed: !this.state.collapsed + collapsed: !this.state.collapsed, }); - } + }; } diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.tsx similarity index 86% rename from x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.tsx index 477ffbebf8699e..8a78748b462323 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.tsx @@ -4,21 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { - EuiButton, + EuiButtonEmpty, + // @ts-ignore EuiConfirmModal, } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; import { DeleteRoleButton } from './delete_role_button'; -import { - shallow, - mount -} from 'enzyme'; test('it renders without crashing', () => { const deleteHandler = jest.fn(); const wrapper = shallow(); - expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(EuiButtonEmpty)).toHaveLength(1); expect(deleteHandler).toHaveBeenCalledTimes(0); }); @@ -26,7 +24,7 @@ test('it shows a confirmation dialog when clicked', () => { const deleteHandler = jest.fn(); const wrapper = mount(); - wrapper.find(EuiButton).simulate('click'); + wrapper.find(EuiButtonEmpty).simulate('click'); expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.js b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.tsx similarity index 63% rename from x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.js rename to x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.tsx index 805dfb232fff48..6bb0483456580e 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.tsx @@ -4,40 +4,44 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; import { - EuiButton, - EuiOverlayMask, + EuiButtonEmpty, EuiConfirmModal, + // @ts-ignore + EuiOverlayMask, } from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; -export class DeleteRoleButton extends Component { - static propTypes = { - canDelete: PropTypes.bool.isRequired, - onDelete: PropTypes.func.isRequired - } +interface Props { + canDelete: boolean; + onDelete: () => void; +} - state = { - showModal: false - } +interface State { + showModal: boolean; +} + +export class DeleteRoleButton extends Component { + public state = { + showModal: false, + }; - render() { + public render() { if (!this.props.canDelete) { return null; } return ( - - Delete Role - + + Delete role + {this.maybeShowModal()} ); } - maybeShowModal = () => { + public maybeShowModal = () => { if (!this.state.showModal) { return null; } @@ -48,7 +52,7 @@ export class DeleteRoleButton extends Component { onCancel={this.closeModal} onConfirm={this.onConfirmDelete} cancelButtonText={"No, don't delete"} - confirmButtonText={"Yes, delete role"} + confirmButtonText={'Yes, delete role'} buttonColor={'danger'} >

Are you sure you want to delete this role?

@@ -56,23 +60,22 @@ export class DeleteRoleButton extends Component { ); - } + }; - closeModal = () => { + public closeModal = () => { this.setState({ - showModal: false + showModal: false, }); - } + }; - showModal = () => { + public showModal = () => { this.setState({ - showModal: true + showModal: true, }); - } + }; - onConfirmDelete = () => { + public onConfirmDelete = () => { this.closeModal(); this.props.onDelete(); - } + }; } - diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.js b/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx similarity index 53% rename from x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.js rename to x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx index 6beb467f06f5ce..b15221336f173a 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx @@ -3,79 +3,107 @@ * 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, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { get } from 'lodash'; -import { toastNotifications } from 'ui/notify'; import { - EuiPanel, - EuiTitle, - EuiSpacer, - EuiPage, + EuiButton, EuiButtonEmpty, - EuiForm, - EuiFormRow, EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiButton, + // @ts-ignore + EuiForm, + EuiFormRow, + EuiPage, + EuiPageBody, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, } from '@elastic/eui'; -import { saveRole, deleteRole } from '../../../../objects'; +import { get } from 'lodash'; +import React, { ChangeEvent, Component, HTMLProps } from 'react'; +import { toastNotifications } from 'ui/notify'; +import { Space } from '../../../../../../spaces/common/model/space'; +import { IndexPrivilege } from '../../../../../common/model/index_privilege'; +import { Role } from '../../../../../common/model/role'; import { isReservedRole } from '../../../../lib/role'; -import { RoleValidator } from '../lib/validate_role'; -import { ReservedRoleBadge } from './reserved_role_badge'; +import { deleteRole, saveRole } from '../../../../objects'; import { ROLES_PATH } from '../../management_urls'; +import { RoleValidationResult, RoleValidator } from '../lib/validate_role'; import { DeleteRoleButton } from './delete_role_button'; import { ElasticsearchPrivileges, KibanaPrivileges } from './privileges'; +import { ReservedRoleBadge } from './reserved_role_badge'; -export class EditRolePage extends Component { - static propTypes = { - role: PropTypes.object.isRequired, - runAsUsers: PropTypes.array.isRequired, - indexPatterns: PropTypes.array.isRequired, - httpClient: PropTypes.func.isRequired, - rbacEnabled: PropTypes.bool.isRequired, - allowDocumentLevelSecurity: PropTypes.bool.isRequired, - allowFieldLevelSecurity: PropTypes.bool.isRequired, - kibanaAppPrivileges: PropTypes.array.isRequired, - notifier: PropTypes.func.isRequired, - }; +interface Props { + role: Role; + runAsUsers: string[]; + indexPatterns: string[]; + httpClient: any; + rbacEnabled: boolean; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; + kibanaAppPrivileges: string[]; + notifier: any; + spaces?: Space[]; + spacesEnabled: boolean; +} - constructor(props) { +interface State { + role: Role; + formError: RoleValidationResult | null; +} + +export class EditRolePage extends Component { + private validator: RoleValidator; + + constructor(props: Props) { super(props); this.state = { role: props.role, - formError: null + formError: null, }; this.validator = new RoleValidator({ shouldValidate: false }); } - render() { + public render() { return ( - - - {this.getFormTitle()} + + + + {this.getFormTitle()} - + {isReservedRole(this.props.role) && ( + +

+ Reserved roles are built-in and cannot be removed or modified. +

+
+ )} - {this.getRoleName()} + - {this.getElasticsearchPrivileges()} + {this.getRoleName()} - {this.getKibanaPrivileges()} + {this.getElasticsearchPrivileges()} - + {this.getKibanaPrivileges()} - {this.getFormButtons()} -
+ + + {this.getFormButtons()} +
+
); } - getFormTitle = () => { + public getFormTitle = () => { let titleText; + const props: HTMLProps = { + tabIndex: 0, + }; if (isReservedRole(this.props.role)) { titleText = 'Viewing role'; + props['aria-describedby'] = 'reservedRoleDescription'; } else if (this.editingExistingRole()) { titleText = 'Edit role'; } else { @@ -83,11 +111,15 @@ export class EditRolePage extends Component { } return ( -

{titleText}

+ +

+ {titleText} +

+
); }; - getActionButton = () => { + public getActionButton = () => { if (this.editingExistingRole() && !isReservedRole(this.props.role)) { return ( @@ -99,42 +131,43 @@ export class EditRolePage extends Component { return null; }; - getRoleName = () => { + public getRoleName = () => { return ( - ); - } + }; - onNameChange = (e) => { + public onNameChange = (e: ChangeEvent) => { const rawValue = e.target.value; - const name = rawValue.replace(/\s/g, '-'); + const name = rawValue.replace(/\s/g, '_'); this.setState({ role: { ...this.state.role, - name - } + name, + }, }); - } + }; - getElasticsearchPrivileges() { + public getElasticsearchPrivileges() { return (
@@ -153,36 +186,32 @@ export class EditRolePage extends Component { ); } - onRoleChange = (role) => { + public onRoleChange = (role: Role) => { this.setState({ - role + role, }); - } - - getKibanaPrivileges = () => { - if (!this.props.rbacEnabled) { - return null; - } + }; + public getKibanaPrivileges = () => { return (
); }; - getFormButtons = () => { + public getFormButtons = () => { if (isReservedRole(this.props.role)) { - return ( - - Return to role list - - ); + return Return to role list; } const saveText = this.editingExistingRole() ? 'Update role' : 'Create role'; @@ -190,10 +219,17 @@ export class EditRolePage extends Component { return ( - {saveText} + + {saveText} + - + Cancel @@ -203,68 +239,63 @@ export class EditRolePage extends Component { ); }; - editingExistingRole = () => { + public editingExistingRole = () => { return !!this.props.role.name; }; - isPlaceholderPrivilege = (indexPrivilege) => { + public isPlaceholderPrivilege = (indexPrivilege: IndexPrivilege) => { return indexPrivilege.names.length === 0; }; - saveRole = () => { + public saveRole = () => { this.validator.enableValidation(); const result = this.validator.validateForSave(this.state.role); if (result.isInvalid) { this.setState({ - formError: result + formError: result, }); } else { this.setState({ - formError: null + formError: null, }); - const { - httpClient, - notifier, - } = this.props; + const { httpClient, notifier } = this.props; const role = { - ...this.state.role + ...this.state.role, }; - role.elasticsearch.indices = role.elasticsearch.indices.filter(i => !this.isPlaceholderPrivilege(i)); - role.elasticsearch.indices.forEach((index) => index.query || delete index.query); + role.elasticsearch.indices = role.elasticsearch.indices.filter( + i => !this.isPlaceholderPrivilege(i) + ); + role.elasticsearch.indices.forEach(index => index.query || delete index.query); saveRole(httpClient, role) .then(() => { toastNotifications.addSuccess('Saved role'); this.backToRoleList(); }) - .catch(error => { + .catch((error: any) => { notifier.error(get(error, 'data.message')); }); } }; - handleDeleteRole = () => { - const { - httpClient, - role, - notifier, - } = this.props; + public handleDeleteRole = () => { + const { httpClient, role, notifier } = this.props; deleteRole(httpClient, role.name) .then(() => { toastNotifications.addSuccess('Deleted role'); this.backToRoleList(); }) - .catch(error => { + .catch((error: any) => { notifier.error(get(error, 'data.message')); }); }; - backToRoleList = () => { + public backToRoleList = () => { window.location.hash = ROLES_PATH; }; } diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/index.js b/x-pack/plugins/security/public/views/management/edit_role/components/index.ts similarity index 100% rename from x-pack/plugins/security/public/views/management/edit_role/components/index.js rename to x-pack/plugins/security/public/views/management/edit_role/components/index.ts diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/cluster_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/cluster_privileges.js deleted file mode 100644 index 400e674f3ca7a2..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/cluster_privileges.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component } from 'react'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import { getClusterPrivileges } from '../../../../../services/role_privileges'; -import { isReservedRole } from '../../../../../lib/role'; -import { - EuiCheckboxGroup, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; - -export class ClusterPrivileges extends Component { - static propTypes = { - role: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - }; - - render() { - - const clusterPrivileges = getClusterPrivileges(); - const privilegeGroups = _.chunk(clusterPrivileges, clusterPrivileges.length / 2); - - return ( - - {privilegeGroups.map(this.buildCheckboxGroup)} - - ); - } - - buildCheckboxGroup = (items, key) => { - const role = this.props.role; - - const checkboxes = items.map(i => ({ - id: i, - label: i - })); - - const selectionMap = (role.elasticsearch.cluster || []) - .map(k => ({ [k]: true })) - .reduce((acc, o) => ({ ...acc, ...o }), {}); - - return ( - - - - ); - }; - - onClusterPrivilegesChange = (privilege) => { - const { cluster } = this.props.role.elasticsearch; - const indexOfExistingPrivilege = cluster.indexOf(privilege); - - const shouldRemove = indexOfExistingPrivilege >= 0; - - const newClusterPrivs = [...cluster]; - if (shouldRemove) { - newClusterPrivs.splice(indexOfExistingPrivilege, 1); - } else { - newClusterPrivs.push(privilege); - } - - this.props.onChange(newClusterPrivs); - } -} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/cluster_privileges.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap similarity index 62% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/cluster_privileges.test.js.snap rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap index 5315e277468ed8..f8875d707b66fd 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/cluster_privileges.test.js.snap +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap @@ -13,79 +13,55 @@ exports[`it renders without crashing 1`] = ` - - - - diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/elasticsearch_privileges.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap similarity index 88% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/elasticsearch_privileges.test.js.snap rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap index 5c8de223b641bf..5e65d164d59c71 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/elasticsearch_privileges.test.js.snap +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap @@ -9,7 +9,8 @@ exports[`it renders without crashing 1`] = ` - Manage the actions this role can perform against your cluster. + Manage the actions this role can perform against your cluster. + @@ -55,7 +61,8 @@ exports[`it renders without crashing 1`] = ` - Allow requests to be submitted on the behalf of other users. + Allow requests to be submitted on the behalf of other users. +

- Control access to the data in your cluster. + Control access to the data in your cluster. + diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/index_privilege_form.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap similarity index 95% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/index_privilege_form.test.js.snap rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap index cba78af4f996bd..d7426919a1f3b2 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/index_privilege_form.test.js.snap +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap @@ -42,6 +42,7 @@ exports[`it renders without crashing 1`] = ` label="Indices" > @@ -178,6 +183,7 @@ exports[`it renders without crashing 1`] = ` aria-label="Delete index privilege" color="danger" iconType="trash" + onClick={[MockFunction]} type="button" /> diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/index_privileges.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privileges.test.tsx.snap similarity index 100% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/__snapshots__/index_privileges.test.js.snap rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privileges.test.tsx.snap diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/cluster_privileges.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.test.tsx similarity index 50% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/cluster_privileges.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.test.tsx index 20bb0846e5b1c8..a3a52a2fc511af 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/cluster_privileges.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.test.tsx @@ -4,17 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { shallow } from 'enzyme'; import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { Role } from '../../../../../../../common/model/role'; import { ClusterPrivileges } from './cluster_privileges'; -import { EuiCheckboxGroup } from '@elastic/eui'; test('it renders without crashing', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); -}); + const role: Role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; -test('it renders 2 checkbox groups of privileges', () => { - const wrapper = mount(); - expect(wrapper.find(EuiCheckboxGroup)).toHaveLength(2); + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.tsx new file mode 100644 index 00000000000000..3dbac8c31ae9c5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.tsx @@ -0,0 +1,54 @@ +/* + * 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 { + // @ts-ignore + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import React, { Component } from 'react'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { getClusterPrivileges } from '../../../../../../services/role_privileges'; + +interface Props { + role: Role; + onChange: (role: Role) => void; +} + +export class ClusterPrivileges extends Component { + public render() { + const clusterPrivileges = getClusterPrivileges(); + + return {this.buildComboBox(clusterPrivileges)}; + } + + public buildComboBox = (items: string[]) => { + const role = this.props.role; + + const options = items.map(i => ({ + label: i, + })); + + const selectedOptions = (role.elasticsearch.cluster || []).map(k => ({ label: k })); + + return ( + + + + ); + }; + + public onClusterPrivilegesChange = (selectedPrivileges: any) => { + this.props.onChange(selectedPrivileges.map((priv: any) => priv.label)); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.less b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.less similarity index 100% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.less rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.less diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.test.tsx similarity index 85% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.test.tsx index fdcec4223e708d..3a948801102a57 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.test.tsx @@ -4,21 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ +import { mount, shallow } from 'enzyme'; import React from 'react'; -import { shallow, mount } from 'enzyme'; -import { IndexPrivileges } from './index_privileges'; +import { RoleValidator } from '../../../lib/validate_role'; import { ClusterPrivileges } from './cluster_privileges'; import { ElasticsearchPrivileges } from './elasticsearch_privileges'; -import { RoleValidator } from '../../lib/validate_role'; +import { IndexPrivileges } from './index_privileges'; test('it renders without crashing', () => { const props = { role: { + name: '', elasticsearch: { cluster: [], indices: [], run_as: [], }, + kibana: { + global: [], + space: {}, + }, }, editable: true, httpClient: jest.fn(), @@ -36,11 +41,16 @@ test('it renders without crashing', () => { test('it renders ClusterPrivileges', () => { const props = { role: { + name: '', elasticsearch: { cluster: [], indices: [], run_as: [], }, + kibana: { + global: [], + space: {}, + }, }, editable: true, httpClient: jest.fn(), @@ -58,11 +68,16 @@ test('it renders ClusterPrivileges', () => { test('it renders IndexPrivileges', () => { const props = { role: { + name: '', elasticsearch: { cluster: [], indices: [], run_as: [], }, + kibana: { + global: [], + space: {}, + }, }, editable: true, httpClient: jest.fn(), diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.tsx similarity index 66% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.tsx index 2a71803a7a2145..88176d55ae7fd4 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.tsx @@ -4,39 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; import { - EuiText, - EuiSpacer, - EuiComboBox, - EuiFormRow, EuiButton, + // @ts-ignore + EuiComboBox, + // @ts-ignore EuiDescribedFormGroup, - EuiTitle, + EuiFormRow, EuiHorizontalRule, EuiLink, + EuiSpacer, + EuiText, + EuiTitle, } from '@elastic/eui'; -import './elasticsearch_privileges.less'; +import React, { Component, Fragment } from 'react'; +import { Role } from '../../../../../../../common/model/role'; +import { documentationLinks } from '../../../../../../documentation_links'; +import { RoleValidator } from '../../../lib/validate_role'; +import { CollapsiblePanel } from '../../collapsible_panel'; import { ClusterPrivileges } from './cluster_privileges'; + import { IndexPrivileges } from './index_privileges'; -import { CollapsiblePanel } from '../collapsible_panel'; -import { documentationLinks } from '../../../../../documentation_links'; - -export class ElasticsearchPrivileges extends Component { - static propTypes = { - role: PropTypes.object.isRequired, - editable: PropTypes.bool.isRequired, - httpClient: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - runAsUsers: PropTypes.array.isRequired, - validator: PropTypes.object.isRequired, - indexPatterns: PropTypes.array.isRequired, - allowDocumentLevelSecurity: PropTypes.bool.isRequired, - allowFieldLevelSecurity: PropTypes.bool.isRequired, - }; - render() { +interface Props { + role: Role; + editable: boolean; + httpClient: any; + onChange: (role: Role) => void; + runAsUsers: string[]; + validator: RoleValidator; + indexPatterns: string[]; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; +} + +export class ElasticsearchPrivileges extends Component { + public render() { return ( {this.getForm()} @@ -44,7 +47,7 @@ export class ElasticsearchPrivileges extends Component { ); } - getForm = () => { + public getForm = () => { const { role, httpClient, @@ -71,7 +74,8 @@ export class ElasticsearchPrivileges extends Component { title={

Cluster privileges

} description={

- Manage the actions this role can perform against your cluster. {this.learnMore(documentationLinks.esClusterPrivileges)} + Manage the actions this role can perform against your cluster.{' '} + {this.learnMore(documentationLinks.esClusterPrivileges)}

} > @@ -86,7 +90,8 @@ export class ElasticsearchPrivileges extends Component { title={

Run As privileges

} description={

- Allow requests to be submitted on the behalf of other users. {this.learnMore(documentationLinks.esRunAsPrivileges)} + Allow requests to be submitted on the behalf of other users.{' '} + {this.learnMore(documentationLinks.esRunAsPrivileges)}

} > @@ -103,10 +108,15 @@ export class ElasticsearchPrivileges extends Component { -

Index privileges

+ +

Index privileges

+
-

Control access to the data in your cluster. {this.learnMore(documentationLinks.esIndicesPrivileges)}

+

+ Control access to the data in your cluster.{' '} + {this.learnMore(documentationLinks.esIndicesPrivileges)} +

@@ -114,39 +124,44 @@ export class ElasticsearchPrivileges extends Component { {this.props.editable && ( - Add index privilege + + Add index privilege + )} ); - } + }; - learnMore = (href) => ( + public learnMore = (href: string) => ( Learn more ); - addIndexPrivilege = () => { + public addIndexPrivilege = () => { const { role } = this.props; - const newIndices = [...role.elasticsearch.indices, { - names: [], - privileges: [], - field_security: { - grant: ['*'] - } - }]; + const newIndices = [ + ...role.elasticsearch.indices, + { + names: [], + privileges: [], + field_security: { + grant: ['*'], + }, + }, + ]; this.props.onChange({ ...this.props.role, elasticsearch: { ...this.props.role.elasticsearch, - indices: newIndices - } + indices: newIndices, + }, }); }; - onClusterPrivilegesChange = (cluster) => { + public onClusterPrivilegesChange = (cluster: string[]) => { const role = { ...this.props.role, elasticsearch: { @@ -156,17 +171,17 @@ export class ElasticsearchPrivileges extends Component { }; this.props.onChange(role); - } + }; - onRunAsUserChange = (users) => { + public onRunAsUserChange = (users: any) => { const role = { ...this.props.role, elasticsearch: { ...this.props.role.elasticsearch, - run_as: users.map(u => u.label), + run_as: users.map((u: any) => u.label), }, }; this.props.onChange(role); - } + }; } diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.test.tsx similarity index 77% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.test.tsx index 1fed539d2526dc..2d686766943044 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.test.tsx @@ -3,22 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { EuiButtonIcon, EuiSwitch, EuiTextArea } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { RoleValidator } from '../../../lib/validate_role'; import { IndexPrivilegeForm } from './index_privilege_form'; -import { RoleValidator } from '../../lib/validate_role'; -import { EuiSwitch, EuiTextArea, EuiButtonIcon } from '@elastic/eui'; test('it renders without crashing', () => { const props = { indexPrivilege: { names: [], privileges: [], - query: null, + query: '', field_security: { - grant: [] - } + grant: [], + }, }, + formIndex: 0, indexPatterns: [], availableFields: [], isReservedRole: false, @@ -26,7 +27,8 @@ test('it renders without crashing', () => { allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), - onChange: jest.fn() + onChange: jest.fn(), + onDelete: jest.fn(), }; const wrapper = shallow(); @@ -38,11 +40,12 @@ describe('delete button', () => { indexPrivilege: { names: [], privileges: [], - query: null, + query: '', field_security: { - grant: [] - } + grant: [], + }, }, + formIndex: 0, indexPatterns: [], availableFields: [], isReservedRole: false, @@ -51,13 +54,13 @@ describe('delete button', () => { allowFieldLevelSecurity: true, validator: new RoleValidator(), onChange: jest.fn(), - onDelete: jest.fn() + onDelete: jest.fn(), }; test('it is hidden when allowDelete is false', () => { const testProps = { ...props, - allowDelete: false + allowDelete: false, }; const wrapper = mount(); expect(wrapper.find(EuiButtonIcon)).toHaveLength(0); @@ -66,7 +69,7 @@ describe('delete button', () => { test('it is shown when allowDelete is true', () => { const testProps = { ...props, - allowDelete: true + allowDelete: true, }; const wrapper = mount(); expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); @@ -75,7 +78,7 @@ describe('delete button', () => { test('it invokes onDelete when clicked', () => { const testProps = { ...props, - allowDelete: true + allowDelete: true, }; const wrapper = mount(); wrapper.find(EuiButtonIcon).simulate('click'); @@ -88,11 +91,12 @@ describe(`document level security`, () => { indexPrivilege: { names: [], privileges: [], - query: "some query", + query: 'some query', field_security: { - grant: [] - } + grant: [], + }, }, + formIndex: 0, indexPatterns: [], availableFields: [], isReservedRole: false, @@ -100,13 +104,14 @@ describe(`document level security`, () => { allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), - onChange: jest.fn() + onChange: jest.fn(), + onDelete: jest.fn(), }; test(`inputs are hidden when DLS is not allowed`, () => { const testProps = { ...props, - allowDocumentLevelSecurity: false + allowDocumentLevelSecurity: false, }; const wrapper = mount(); @@ -119,8 +124,8 @@ describe(`document level security`, () => { ...props, indexPrivilege: { ...props.indexPrivilege, - query: null - } + query: '', + }, }; const wrapper = mount(); @@ -144,11 +149,12 @@ describe('field level security', () => { indexPrivilege: { names: [], privileges: [], - query: null, + query: '', field_security: { - grant: ["foo*"] - } + grant: ['foo*'], + }, }, + formIndex: 0, indexPatterns: [], availableFields: [], isReservedRole: false, @@ -156,17 +162,18 @@ describe('field level security', () => { allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), - onChange: jest.fn() + onChange: jest.fn(), + onDelete: jest.fn(), }; test(`input is hidden when FLS is not allowed`, () => { const testProps = { ...props, - allowFieldLevelSecurity: false + allowFieldLevelSecurity: false, }; const wrapper = mount(); - expect(wrapper.find(".indexPrivilegeForm__grantedFieldsRow")).toHaveLength(0); + expect(wrapper.find('.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(0); }); test('input is shown when allowed', () => { @@ -175,7 +182,7 @@ describe('field level security', () => { }; const wrapper = mount(); - expect(wrapper.find("div.indexPrivilegeForm__grantedFieldsRow")).toHaveLength(1); + expect(wrapper.find('div.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(1); }); test('it displays a warning when no fields are granted', () => { @@ -184,23 +191,23 @@ describe('field level security', () => { indexPrivilege: { ...props.indexPrivilege, field_security: { - grant: [] - } - } + grant: [], + }, + }, }; const wrapper = mount(); - expect(wrapper.find("div.indexPrivilegeForm__grantedFieldsRow")).toHaveLength(1); - expect(wrapper.find(".euiFormHelpText")).toHaveLength(1); + expect(wrapper.find('div.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(1); + expect(wrapper.find('.euiFormHelpText')).toHaveLength(1); }); test('it does not display a warning when fields are granted', () => { const testProps = { - ...props + ...props, }; const wrapper = mount(); - expect(wrapper.find("div.indexPrivilegeForm__grantedFieldsRow")).toHaveLength(1); - expect(wrapper.find(".euiFormHelpText")).toHaveLength(0); + expect(wrapper.find('div.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(1); + expect(wrapper.find('.euiFormHelpText')).toHaveLength(0); }); }); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.tsx similarity index 62% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.tsx index ee62b80c5e282f..0468afdb6b9890 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.tsx @@ -3,57 +3,69 @@ * 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, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; import { + // @ts-ignore + EuiButtonIcon, EuiComboBox, - EuiTextArea, - EuiFormRow, EuiFlexGroup, EuiFlexItem, - EuiSwitch, - EuiSpacer, + EuiFormRow, EuiHorizontalRule, - EuiButtonIcon, + EuiSpacer, + EuiSwitch, + EuiTextArea, } from '@elastic/eui'; -import { getIndexPrivileges } from '../../../../../services/role_privileges'; +import React, { ChangeEvent, Component, Fragment } from 'react'; +import { IndexPrivilege } from '../../../../../../../common/model/index_privilege'; +import { getIndexPrivileges } from '../../../../../../services/role_privileges'; +import { RoleValidator } from '../../../lib/validate_role'; -const fromOption = (option) => option.label; -const toOption = (value) => ({ label: value }); +const fromOption = (option: any) => option.label; +const toOption = (value: string) => ({ label: value }); -export class IndexPrivilegeForm extends Component { - static propTypes = { - indexPrivilege: PropTypes.object.isRequired, - indexPatterns: PropTypes.array.isRequired, - availableFields: PropTypes.array, - onChange: PropTypes.func.isRequired, - isReservedRole: PropTypes.bool.isRequired, - allowDelete: PropTypes.bool.isRequired, - allowDocumentLevelSecurity: PropTypes.bool.isRequired, - allowFieldLevelSecurity: PropTypes.bool.isRequired, - validator: PropTypes.object.isRequired, - }; +interface Props { + formIndex: number; + indexPrivilege: IndexPrivilege; + indexPatterns: string[]; + availableFields: string[]; + onChange: (indexPrivilege: IndexPrivilege) => void; + onDelete: () => void; + isReservedRole: boolean; + allowDelete: boolean; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; + validator: RoleValidator; +} - constructor(props) { +interface State { + queryExpanded: boolean; + documentQuery?: string; +} + +export class IndexPrivilegeForm extends Component { + constructor(props: Props) { super(props); this.state = { queryExpanded: !!props.indexPrivilege.query, - documentQuery: props.indexPrivilege.query + documentQuery: props.indexPrivilege.query, }; } - render() { + public render() { return ( - - {this.getPrivilegeForm()} - + {this.getPrivilegeForm()} {this.props.allowDelete && ( - + )} @@ -62,13 +74,18 @@ export class IndexPrivilegeForm extends Component { ); } - getPrivilegeForm = () => { + public getPrivilegeForm = () => { return ( - + { - const { - allowFieldLevelSecurity, - availableFields, - indexPrivilege, - isReservedRole, - } = this.props; + public getGrantedFieldsControl = () => { + const { allowFieldLevelSecurity, availableFields, indexPrivilege, isReservedRole } = this.props; if (!allowFieldLevelSecurity) { return null; @@ -119,12 +132,14 @@ export class IndexPrivilegeForm extends Component { fullWidth={true} className="indexPrivilegeForm__grantedFieldsRow" helpText={ - !isReservedRole && grant.length === 0 ? - 'If no fields are granted, then users assigned to this role will not be able to see any data for this index.' : undefined + !isReservedRole && grant.length === 0 + ? 'If no fields are granted, then users assigned to this role will not be able to see any data for this index.' + : undefined } > { - const { - allowDocumentLevelSecurity, - indexPrivilege, - } = this.props; + public getGrantedDocumentsControl = () => { + const { allowDocumentLevelSecurity, indexPrivilege } = this.props; if (!allowDocumentLevelSecurity) { return null; } return ( + // @ts-ignore - {!this.props.isReservedRole && + {!this.props.isReservedRole && ( - } - {this.state.queryExpanded && + )} + {this.state.queryExpanded && ( - } + )} ); }; - toggleDocumentQuery = () => { - const willToggleOff = this.state.queryExanded; + public toggleDocumentQuery = () => { + const willToggleOff = this.state.queryExpanded; const willToggleOn = !willToggleOff; // If turning off, then save the current query in state so that we can restore it if the user changes their mind. this.setState({ queryExpanded: !this.state.queryExpanded, - documentQuery: willToggleOff ? this.props.indexPrivilege.query : this.state.documentQuery + documentQuery: willToggleOff ? this.props.indexPrivilege.query : this.state.documentQuery, }); // If turning off, then remove the query from the Index Privilege @@ -206,7 +223,7 @@ export class IndexPrivilegeForm extends Component { } }; - onCreateIndexPatternOption = (option) => { + public onCreateIndexPatternOption = (option: any) => { const newIndexPatterns = this.props.indexPrivilege.names.concat([option]); this.props.onChange({ @@ -215,28 +232,35 @@ export class IndexPrivilegeForm extends Component { }); }; - onIndexPatternsChange = (newPatterns) => { + public onIndexPatternsChange = (newPatterns: string[]) => { this.props.onChange({ ...this.props.indexPrivilege, names: newPatterns.map(fromOption), }); }; - onPrivilegeChange = (newPrivileges) => { + public onPrivilegeChange = (newPrivileges: string[]) => { this.props.onChange({ ...this.props.indexPrivilege, privileges: newPrivileges.map(fromOption), }); }; - onQueryChange = (e) => { + public onQueryChange = (e: ChangeEvent) => { this.props.onChange({ ...this.props.indexPrivilege, query: e.target.value, }); }; - onCreateGrantedField = (grant) => { + public onCreateGrantedField = (grant: string) => { + if ( + !this.props.indexPrivilege.field_security || + !this.props.indexPrivilege.field_security.grant + ) { + return; + } + const newGrants = this.props.indexPrivilege.field_security.grant.concat([grant]); this.props.onChange({ @@ -248,7 +272,7 @@ export class IndexPrivilegeForm extends Component { }); }; - onGrantedFieldsChange = (grantedFields) => { + public onGrantedFieldsChange = (grantedFields: string[]) => { this.props.onChange({ ...this.props.indexPrivilege, field_security: { diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.test.tsx similarity index 73% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.test.tsx index 3a4fb950e555d5..7e8b71fd93ae21 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.test.tsx @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { mount, shallow } from 'enzyme'; import React from 'react'; -import { shallow, mount } from 'enzyme'; -import { IndexPrivileges } from './index_privileges'; +import { RoleValidator } from '../../../lib/validate_role'; import { IndexPrivilegeForm } from './index_privilege_form'; -import { RoleValidator } from '../../lib/validate_role'; +import { IndexPrivileges } from './index_privileges'; test('it renders without crashing', () => { const props = { role: { + name: '', elasticsearch: { cluster: [], indices: [], run_as: [], }, + kibana: { + global: [], + space: {}, + }, }, httpClient: jest.fn(), onChange: jest.fn(), @@ -33,16 +38,23 @@ test('it renders without crashing', () => { test('it renders a IndexPrivilegeForm for each privilege on the role', () => { const props = { role: { + name: '', + kibana: { + global: [], + space: {}, + }, elasticsearch: { cluster: [], - indices: [{ - names: ['foo*'], - privileges: ['all'], - query: '*', - field_security: { - grant: ['some_field'] - } - }], + indices: [ + { + names: ['foo*'], + privileges: ['all'], + query: '*', + field_security: { + grant: ['some_field'], + }, + }, + ], run_as: [], }, }, diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.tsx similarity index 58% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.js rename to x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.tsx index 601c855c760769..e5e5648db06ab9 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.tsx @@ -3,40 +3,47 @@ * 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, { Component } from 'react'; -import PropTypes from 'prop-types'; import _ from 'lodash'; -import { isReservedRole, isRoleEnabled } from '../../../../../lib/role'; +import React, { Component } from 'react'; +import { IndexPrivilege } from '../../../../../../../common/model/index_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole, isRoleEnabled } from '../../../../../../lib/role'; +import { getFields } from '../../../../../../objects'; +import { RoleValidator } from '../../../lib/validate_role'; import { IndexPrivilegeForm } from './index_privilege_form'; -import { getFields } from '../../../../../objects'; - -export class IndexPrivileges extends Component { - static propTypes = { - role: PropTypes.object.isRequired, - indexPatterns: PropTypes.array.isRequired, - allowDocumentLevelSecurity: PropTypes.bool.isRequired, - allowFieldLevelSecurity: PropTypes.bool.isRequired, - httpClient: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - validator: PropTypes.object.isRequired, - } - state = { - availableFields: {} +interface Props { + role: Role; + indexPatterns: string[]; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; + httpClient: any; + onChange: (role: Role) => void; + validator: RoleValidator; +} + +interface State { + availableFields: { + [indexPrivKey: string]: string[]; + }; +} + +export class IndexPrivileges extends Component { + constructor(props: Props) { + super(props); + this.state = { + availableFields: {}, + }; } - componentDidMount() { + public componentDidMount() { this.loadAvailableFields(this.props.role.elasticsearch.indices); } - render() { + public render() { const { indices = [] } = this.props.role.elasticsearch; - const { - indexPatterns, - allowDocumentLevelSecurity, - allowFieldLevelSecurity - } = this.props; + const { indexPatterns, allowDocumentLevelSecurity, allowFieldLevelSecurity } = this.props; const props = { indexPatterns, @@ -45,13 +52,14 @@ export class IndexPrivileges extends Component { // doesn't permit FLS/DLS). allowDocumentLevelSecurity: allowDocumentLevelSecurity || !isRoleEnabled(this.props.role), allowFieldLevelSecurity: allowFieldLevelSecurity || !isRoleEnabled(this.props.role), - isReservedRole: isReservedRole(this.props.role) + isReservedRole: isReservedRole(this.props.role), }; - const forms = indices.map((indexPrivilege, idx) => ( + const forms = indices.map((indexPrivilege: IndexPrivilege, idx) => ( { + public addIndexPrivilege = () => { const { role } = this.props; - const newIndices = [...role.elasticsearch.indices, { - names: [], - privileges: [], - field_security: { - grant: ['*'] - } - }]; + const newIndices = [ + ...role.elasticsearch.indices, + { + names: [], + privileges: [], + field_security: { + grant: ['*'], + }, + }, + ]; this.props.onChange({ ...this.props.role, @@ -84,8 +95,8 @@ export class IndexPrivileges extends Component { }); }; - onIndexPrivilegeChange = (privilegeIndex) => { - return (updatedPrivilege) => { + public onIndexPrivilegeChange = (privilegeIndex: number) => { + return (updatedPrivilege: IndexPrivilege) => { const { role } = this.props; const { indices } = role.elasticsearch; @@ -104,7 +115,7 @@ export class IndexPrivileges extends Component { }; }; - onIndexPrivilegeDelete = (privilegeIndex) => { + public onIndexPrivilegeDelete = (privilegeIndex: number) => { return () => { const { role } = this.props; @@ -115,53 +126,52 @@ export class IndexPrivileges extends Component { ...this.props.role, elasticsearch: { ...this.props.role.elasticsearch, - indices: newIndices + indices: newIndices, }, }); }; - } + }; - isPlaceholderPrivilege = (indexPrivilege) => { + public isPlaceholderPrivilege = (indexPrivilege: IndexPrivilege) => { return indexPrivilege.names.length === 0; }; - loadAvailableFields(indices) { + public loadAvailableFields(privileges: IndexPrivilege[]) { // Reserved roles cannot be edited, and therefore do not need to fetch available fields. if (isReservedRole(this.props.role)) { return; } - const patterns = indices.map(index => index.names.join(',')); + const patterns = privileges.map(index => index.names.join(',')); const cachedPatterns = Object.keys(this.state.availableFields); const patternsToFetch = _.difference(patterns, cachedPatterns); const fetchRequests = patternsToFetch.map(this.loadFieldsForPattern); - Promise.all(fetchRequests) - .then(response => { - - this.setState({ - availableFields: { - ...this.state.availableFields, - ...response.reduce((acc, o) => ({ ...acc, ...o }), {}) - } - }); + Promise.all(fetchRequests).then(response => { + this.setState({ + availableFields: { + ...this.state.availableFields, + ...response.reduce((acc, o) => ({ ...acc, ...o }), {}), + }, }); + }); } - loadFieldsForPattern = async (pattern) => { - if (!pattern) return { [pattern]: [] }; + public loadFieldsForPattern = async (pattern: string) => { + if (!pattern) { + return { [pattern]: [] }; + } try { return { - [pattern]: await getFields(this.props.httpClient, pattern) + [pattern]: await getFields(this.props.httpClient, pattern), }; - } catch (e) { return { - [pattern]: [] + [pattern]: [], }; } - } + }; } diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.ts b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.ts new file mode 100644 index 00000000000000..a06b14f80fa486 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { ElasticsearchPrivileges } from './es/elasticsearch_privileges'; +export { KibanaPrivileges } from './kibana/kibana_privileges'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/impacted_spaces_flyout.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/impacted_spaces_flyout.test.tsx.snap new file mode 100644 index 00000000000000..a2723f68e1c1b9 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/impacted_spaces_flyout.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + +
+ + See summary of all spaces privileges + +
+
+`; 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 new file mode 100644 index 00000000000000..8c8d75b4201bed --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + + + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_callout_warning.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_callout_warning.test.tsx.snap new file mode 100644 index 00000000000000..53f3fc716d65e8 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_callout_warning.test.tsx.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PrivilegeCalloutWarning renders without crashing 1`] = ` + + +
+
+ + + Minimum privilege is too high to customize individual spaces + +
+ +
+

+ Setting the minimum privilege to + + all + + grants full access to all spaces. To customize privileges for individual spaces, the minimum privilege must be either + + read + + or + + none + + . +

+
+
+
+
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_space_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_space_form.test.tsx.snap new file mode 100644 index 00000000000000..afce653a9c7f4e --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_space_form.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + + + + + + + + + + + + + + + + + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/simple_privilege_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/simple_privilege_form.test.tsx.snap new file mode 100644 index 00000000000000..addaf7437816cc --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/simple_privilege_form.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + + + Specifies the Kibana privilege for this role. +

+ } + fullWidth={false} + gutterSize="l" + title={ +

+ Kibana privileges +

+ } + titleSize="xs" + > + + + +
+
+`; 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 new file mode 100644 index 00000000000000..55f9f818f45e76 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap @@ -0,0 +1,215 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` hides the space table if there are no existing space privileges 1`] = ` + +`; + +exports[` renders without crashing 1`] = ` + + + Specifies the lowest permission level for all spaces, unless a custom privilege is specified. +

+ } + fullWidth={false} + gutterSize="l" + title={ +

+ Minimum privilege +

+ } + titleSize="xs" + > + + + +
+ + + +

+ Space privileges +

+
+ + +

+ Customize permission levels per space. If a space is not customized, its permissions will default to the minimum privilege specified above. +

+

+ You can bulk-create space privileges though they will be saved individually upon saving the role. +

+
+ + + + + + + Add space privilege + + + + + + +
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_selector.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_selector.test.tsx.snap new file mode 100644 index 00000000000000..f22cf87c9c9d6f --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_selector.test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpaceSelector renders without crashing 1`] = ` + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.less b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.less new file mode 100644 index 00000000000000..19f6c14a4a6f9b --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.less @@ -0,0 +1,3 @@ +.showImpactedSpaces--flyout--footer, .showImpactedSpaces { + text-align: right; +} 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 new file mode 100644 index 00000000000000..674b44ef3dbf68 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx @@ -0,0 +1,153 @@ +/* + * 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 { EuiFlyout, EuiLink } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { ImpactedSpacesFlyout } from './impacted_spaces_flyout'; +import { PrivilegeSpaceTable } from './privilege_space_table'; + +const buildProps = (customProps = {}) => { + return { + role: { + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + spaces: [ + { + id: 'default', + name: 'Default Space', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + }, + ], + kibanaAppPrivileges: [ + { + name: 'all', + }, + { + name: 'read', + }, + ], + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('does not immediately show the flyout', () => { + const wrapper = mount(); + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + + it('shows the flyout after clicking the link', () => { + const wrapper = mount(); + wrapper.find(EuiLink).simulate('click'); + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + }); + + describe('with base privilege set to "all"', () => { + it('calculates the effective privileges correctly', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['all'], + space: { + marketing: ['read'], + }, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(EuiLink).simulate('click'); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table.props()).toMatchObject({ + spacePrivileges: { + default: ['all'], + // base privilege of "all" supercedes specified privilege of "read" above + marketing: ['all'], + }, + }); + }); + }); + + describe('with base privilege set to "read"', () => { + it('calculates the effective privileges correctly', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + marketing: ['all'], + }, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(EuiLink).simulate('click'); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table.props()).toMatchObject({ + spacePrivileges: { + default: ['read'], + marketing: ['all'], + }, + }); + }); + }); + + describe('with base privilege set to "none"', () => { + it('calculates the effective privileges correctly', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: [], + space: { + marketing: ['all'], + }, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(EuiLink).simulate('click'); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table.props()).toMatchObject({ + spacePrivileges: { + default: ['none'], + marketing: ['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 new file mode 100644 index 00000000000000..8e50e6e3d0e3b3 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx @@ -0,0 +1,126 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiTitle, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { PrivilegeSpaceTable } from './privilege_space_table'; + +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { ManageSpacesButton } from '../../../../../../../../spaces/public/components'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; +import './impacted_spaces_flyout.less'; + +interface Props { + role: Role; + spaces: Space[]; +} + +interface State { + showImpactedSpaces: boolean; +} + +export class ImpactedSpacesFlyout extends Component { + constructor(props: Props) { + super(props); + this.state = { + showImpactedSpaces: false, + }; + } + + public render() { + const flyout = this.getFlyout(); + return ( + +
+ + See summary of all spaces privileges + +
+ {flyout} +
+ ); + } + + public toggleShowImpactedSpaces = () => { + this.setState({ + showImpactedSpaces: !this.state.showImpactedSpaces, + }); + }; + + public getHighestPrivilege(...privileges: KibanaPrivilege[]): KibanaPrivilege { + if (privileges.indexOf('all') >= 0) { + return 'all'; + } + if (privileges.indexOf('read') >= 0) { + return 'read'; + } + return 'none'; + } + + public getFlyout = () => { + if (!this.state.showImpactedSpaces) { + return null; + } + + const { role, spaces } = this.props; + + const assignedPrivileges = role.kibana; + const basePrivilege = assignedPrivileges.global.length + ? assignedPrivileges.global[0] + : NO_PRIVILEGE_VALUE; + + const allSpacePrivileges = spaces.reduce( + (acc, space) => { + const spacePrivilege = assignedPrivileges.space[space.id] + ? assignedPrivileges.space[space.id][0] + : basePrivilege; + const actualPrivilege = this.getHighestPrivilege(spacePrivilege, basePrivilege); + + return { + ...acc, + // Use the privilege assigned to the space, if provided. Otherwise, the baes privilege is used. + [space.id]: [actualPrivilege], + }; + }, + { ...role.kibana.space } + ); + + return ( + + + +

Summary of all space privileges

+
+
+ + + + + {/* 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 new file mode 100644 index 00000000000000..24990ad1195cd4 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { KibanaPrivileges } from './kibana_privileges'; +import { SimplePrivilegeForm } from './simple_privilege_form'; +import { SpaceAwarePrivilegeForm } from './space_aware_privilege_form'; + +const buildProps = (customProps = {}) => { + return { + role: { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + spacesEnabled: true, + spaces: [ + { + id: 'default', + name: 'Default Space', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + }, + ], + editable: true, + kibanaAppPrivileges: ['all', 'read'], + onChange: jest.fn(), + validator: new RoleValidator(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders the simple privilege form when spaces is disabled', () => { + const props = buildProps({ spacesEnabled: false }); + const wrapper = shallow(); + expect(wrapper.find(SimplePrivilegeForm)).toHaveLength(1); + expect(wrapper.find(SpaceAwarePrivilegeForm)).toHaveLength(0); + }); + + it('renders the space-aware privilege form when spaces is enabled', () => { + const props = buildProps({ spacesEnabled: true }); + const wrapper = shallow(); + expect(wrapper.find(SimplePrivilegeForm)).toHaveLength(0); + expect(wrapper.find(SpaceAwarePrivilegeForm)).toHaveLength(1); + }); +}); 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 new file mode 100644 index 00000000000000..db74c810027fb0 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx @@ -0,0 +1,67 @@ +/* + * 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, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role } from '../../../../../../../common/model/role'; +import { RoleValidator } from '../../../lib/validate_role'; +import { CollapsiblePanel } from '../../collapsible_panel'; +import { SimplePrivilegeForm } from './simple_privilege_form'; +import { SpaceAwarePrivilegeForm } from './space_aware_privilege_form'; + +interface Props { + role: Role; + spacesEnabled: boolean; + spaces?: Space[]; + editable: boolean; + kibanaAppPrivileges: string[]; + onChange: (role: Role) => void; + validator: RoleValidator; +} + +export class KibanaPrivileges extends Component { + public render() { + return ( + + {this.getForm()} + + ); + } + + public getForm = () => { + const { + kibanaAppPrivileges, + role, + spacesEnabled, + spaces = [], + onChange, + editable, + validator, + } = this.props; + + if (spacesEnabled) { + return ( + + ); + } else { + return ( + + ); + } + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.test.tsx new file mode 100644 index 00000000000000..1e8d3f3c391584 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.test.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { PrivilegeCalloutWarning } from './privilege_callout_warning'; + +describe('PrivilegeCalloutWarning', () => { + it('renders without crashing', () => { + expect( + mount() + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.tsx new file mode 100644 index 00000000000000..6641f43074cbe1 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.tsx @@ -0,0 +1,103 @@ +/* + * 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 { EuiCallOut } from '@elastic/eui'; +import React, { Component } from 'react'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; + +interface Props { + basePrivilege: KibanaPrivilege; + isReservedRole: boolean; +} + +interface State { + showImpactedSpaces: boolean; +} + +export class PrivilegeCalloutWarning extends Component { + public state = { + showImpactedSpaces: false, + }; + + public render() { + const { basePrivilege, isReservedRole } = this.props; + + let callout = null; + + if (basePrivilege === 'all') { + if (isReservedRole) { + callout = ( + +

+ This role always grants full access to all spaces. To customize privileges for + individual spaces, you must create a new role. +

+
+ ); + } else { + callout = ( + +

+ Setting the minimum privilege to all grants full access to all + spaces. To customize privileges for individual spaces, the minimum privilege must be + either read or none. +

+
+ ); + } + } + + if (basePrivilege === 'read') { + if (isReservedRole) { + callout = ( + +

+ This role always grants read access to all spaces. To customize privileges for + individual spaces, you must create a new role. +

+
+ ); + } else { + callout = ( + + ); + } + } + + if (basePrivilege === NO_PRIVILEGE_VALUE && isReservedRole) { + callout = ( + +

+ This role never grants access to any spaces within Kibana. To customize privileges for + individual spaces, you must create a new role. +

+
+ ); + } + + return callout; + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_selector.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_selector.tsx new file mode 100644 index 00000000000000..5f7d902cb7459d --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_selector.tsx @@ -0,0 +1,60 @@ +/* + * 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 { + // @ts-ignore + EuiSelect, +} from '@elastic/eui'; +import React, { ChangeEvent, Component } from 'react'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; + +interface Props { + ['data-test-subj']: string; + availablePrivileges: KibanaPrivilege[]; + onChange: (privilege: KibanaPrivilege) => void; + value: KibanaPrivilege | null; + allowNone?: boolean; + disabled?: boolean; + compressed?: boolean; +} + +export class PrivilegeSelector extends Component { + public state = {}; + + public render() { + const { availablePrivileges, value, disabled, allowNone, compressed } = this.props; + + const options = []; + + if (allowNone) { + options.push({ value: NO_PRIVILEGE_VALUE, text: 'none' }); + } + + options.push( + ...availablePrivileges.map(p => ({ + value: p, + text: p, + })) + ); + + return ( + + ); + } + + public onChange = (e: ChangeEvent) => { + this.props.onChange(e.target.value as KibanaPrivilege); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.test.tsx new file mode 100644 index 00000000000000..c51dd1786904fd --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { PrivilegeSpaceForm } from './privilege_space_form'; + +const buildProps = (customProps = {}) => { + return { + availableSpaces: [ + { + id: 'default', + name: 'Default Space', + description: '', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + description: '', + }, + ], + selectedSpaceIds: [], + availablePrivileges: ['all', 'read'], + selectedPrivilege: 'none', + onChange: jest.fn(), + onDelete: jest.fn(), + validator: new RoleValidator(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.tsx new file mode 100644 index 00000000000000..5cd139c0f42e36 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.tsx @@ -0,0 +1,94 @@ +/* + * 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 { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { RoleValidator } from '../../../lib/validate_role'; +import { PrivilegeSelector } from './privilege_selector'; +import { SpaceSelector } from './space_selector'; + +interface Props { + availableSpaces: Space[]; + selectedSpaceIds: string[]; + availablePrivileges: KibanaPrivilege[]; + selectedPrivilege: KibanaPrivilege | null; + onChange: ( + params: { + spaces: string[]; + privilege: KibanaPrivilege | null; + } + ) => void; + onDelete: () => void; + validator: RoleValidator; +} + +export class PrivilegeSpaceForm extends Component { + public render() { + const { + availableSpaces, + selectedSpaceIds, + availablePrivileges, + selectedPrivilege, + validator, + } = this.props; + + return ( + + + + + + + + + + + + + + + + + + ); + } + + public onSelectedSpacesChange = (selectedSpaceIds: string[]) => { + this.props.onChange({ + spaces: selectedSpaceIds, + privilege: this.props.selectedPrivilege, + }); + }; + + public onPrivilegeChange = (privilege: KibanaPrivilege) => { + this.props.onChange({ + spaces: this.props.selectedSpaceIds, + privilege, + }); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_table.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_table.tsx new file mode 100644 index 00000000000000..0f206dcc1e17d6 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_table.tsx @@ -0,0 +1,191 @@ +/* + * 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 { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + // @ts-ignore + EuiInMemoryTable, + EuiText, +} from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { SpaceAvatar } from '../../../../../../../../spaces/public/components'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { PrivilegeSelector } from './privilege_selector'; + +interface Props { + role: Role; + spaces: Space[]; + availablePrivileges?: KibanaPrivilege[]; + spacePrivileges: any; + onChange?: (privs: { [spaceId: string]: KibanaPrivilege[] }) => void; + readonly?: boolean; +} + +interface State { + searchTerm: string; +} + +interface DeletedSpace extends Space { + deleted: boolean; +} + +export class PrivilegeSpaceTable extends Component { + public state = { + searchTerm: '', + }; + + public render() { + const { role, spaces, availablePrivileges, spacePrivileges } = this.props; + + const { searchTerm } = this.state; + + const allTableItems = Object.keys(spacePrivileges) + .map(spaceId => { + return { + space: spaces.find(s => s.id === spaceId) || { id: spaceId, name: '', deleted: true }, + privilege: spacePrivileges[spaceId][0], + }; + }) + .sort(item1 => { + const isDeleted = 'deleted' in item1.space; + return isDeleted ? 1 : -1; + }); + + const visibleTableItems = allTableItems.filter(item => { + const isDeleted = 'deleted' in item.space; + const searchField = isDeleted ? item.space.id : item.space.name; + return searchField.toLowerCase().indexOf(searchTerm) >= 0; + }); + + if (allTableItems.length === 0) { + return null; + } + + return ( + { + this.setState({ + searchTerm: search.queryText.toLowerCase(), + }); + }, + }} + items={visibleTableItems} + /> + ); + } + + public getTableColumns = (role: Role, availablePrivileges: KibanaPrivilege[] = []) => { + const columns: any[] = [ + { + field: 'space', + name: 'Space', + width: this.props.readonly ? '75%' : '50%', + render: (space: Space | DeletedSpace) => { + let content; + if ('deleted' in space) { + content = [ + + {space.id} (deleted) + , + ]; + } else { + content = [ + + + , + + {space.name} + , + ]; + } + return ( + + {content} + + ); + }, + }, + { + field: 'privilege', + name: 'Privilege', + width: this.props.readonly ? '25%' : undefined, + render: (privilege: KibanaPrivilege, record: any) => { + if (this.props.readonly || record.space.deleted) { + return privilege; + } + + return ( + + ); + }, + }, + ]; + if (!this.props.readonly) { + columns.push({ + name: 'Actions', + actions: [ + { + render: (record: any) => { + return ( + this.onDeleteSpacePermissionsClick(record)} + iconType={'trash'} + /> + ); + }, + }, + ], + }); + } + + return columns; + }; + + public onSpacePermissionChange = (record: any) => (selectedPrivilege: KibanaPrivilege) => { + const { id: spaceId } = record.space; + + const updatedPrivileges = { + ...this.props.spacePrivileges, + }; + updatedPrivileges[spaceId] = [selectedPrivilege]; + if (this.props.onChange) { + this.props.onChange(updatedPrivileges); + } + }; + + public onDeleteSpacePermissionsClick = (record: any) => { + const { id: spaceId } = record.space; + + const updatedPrivileges = { + ...this.props.spacePrivileges, + }; + delete updatedPrivileges[spaceId]; + if (this.props.onChange) { + this.props.onChange(updatedPrivileges); + } + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.test.tsx new file mode 100644 index 00000000000000..018aed7df9bb18 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { PrivilegeSelector } from './privilege_selector'; +import { SimplePrivilegeForm } from './simple_privilege_form'; + +const buildProps = (customProps?: any) => { + return { + role: { + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + editable: true, + kibanaAppPrivileges: [ + { + name: 'all', + }, + { + name: 'read', + }, + ], + onChange: jest.fn(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('displays "none" when no privilege is selected', () => { + const props = buildProps(); + const wrapper = shallow(); + const selector = wrapper.find(PrivilegeSelector); + expect(selector.props()).toMatchObject({ + value: 'none', + }); + }); + + it('displays the selected privilege', () => { + const props = buildProps({ + role: { + elasticsearch: {}, + kibana: { + global: ['read'], + }, + }, + }); + const wrapper = shallow(); + const selector = wrapper.find(PrivilegeSelector); + expect(selector.props()).toMatchObject({ + value: 'read', + }); + }); + + it('fires its onChange callback when the privilege changes', () => { + const props = buildProps(); + const wrapper = mount(); + const selector = wrapper.find(PrivilegeSelector).find('select'); + selector.simulate('change', { target: { value: 'all' } }); + + expect(props.onChange).toHaveBeenCalledWith({ + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: {}, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.tsx new file mode 100644 index 00000000000000..4d8892d88fce4d --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.tsx @@ -0,0 +1,72 @@ +/* + * 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 { + // @ts-ignore + EuiDescribedFormGroup, + EuiFormRow, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { KibanaApplicationPrivilege } from '../../../../../../../common/model/kibana_application_privilege'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; +import { copyRole } from '../../../lib/copy_role'; +import { PrivilegeSelector } from './privilege_selector'; + +interface Props { + kibanaAppPrivileges: KibanaApplicationPrivilege[]; + role: Role; + onChange: (role: Role) => void; + editable: boolean; +} + +export class SimplePrivilegeForm extends Component { + public render() { + const { kibanaAppPrivileges, role } = this.props; + + const assignedPrivileges = role.kibana; + const availablePrivileges = kibanaAppPrivileges.map(privilege => privilege.name); + + const kibanaPrivilege: KibanaPrivilege = + assignedPrivileges.global.length > 0 + ? (assignedPrivileges.global[0] as KibanaPrivilege) + : NO_PRIVILEGE_VALUE; + + const description =

Specifies the Kibana privilege for this role.

; + + return ( + + Kibana privileges} description={description}> + + + + + + ); + } + + public onKibanaPrivilegeChange = (privilege: KibanaPrivilege) => { + const role = copyRole(this.props.role); + + // Remove base privilege value + role.kibana.global = []; + + if (privilege !== NO_PRIVILEGE_VALUE) { + role.kibana.global = [privilege]; + } + + this.props.onChange(role); + }; +} 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 new file mode 100644 index 00000000000000..5279f178709323 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx @@ -0,0 +1,239 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { PrivilegeCalloutWarning } from './privilege_callout_warning'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeSpaceTable } from './privilege_space_table'; +import { SpaceAwarePrivilegeForm } from './space_aware_privilege_form'; + +const buildProps = (customProps: any = {}) => { + return { + role: { + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + spaces: [ + { + id: 'default', + name: 'Default Space', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + }, + ], + editable: true, + kibanaAppPrivileges: [ + { + name: 'all', + }, + { + name: 'read', + }, + ], + onChange: jest.fn(), + validator: new RoleValidator(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('shows the space table if exisitng space privileges are declared', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(1); + }); + + it('hides the space table if there are no existing space privileges', () => { + const props = buildProps(); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toMatchSnapshot(); + }); + + it('adds a form row when clicking the "Add Space Privilege" button', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(0); + + wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]').simulate('click'); + + expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(1); + }); + + describe('with minimum privilege set to "all"', () => { + it('does not allow space privileges to be customized', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['all'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const warning = wrapper.find(PrivilegeCalloutWarning); + expect(warning.props()).toMatchObject({ + basePrivilege: 'all', + }); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(0); + + const addPrivilegeButton = wrapper.find('[data-test-subj="addSpacePrivilegeButton"]'); + expect(addPrivilegeButton).toHaveLength(0); + }); + }); + + describe('with minimum privilege set to "read"', () => { + it('shows a warning about minimum privilege', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const warning = wrapper.find(PrivilegeCalloutWarning); + expect(warning.props()).toMatchObject({ + basePrivilege: 'read', + }); + }); + + it('allows space privileges to be customized', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(1); + + const addPrivilegeButton = wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]'); + expect(addPrivilegeButton).toHaveLength(1); + }); + }); + + describe('with minimum privilege set to "none"', () => { + it('does not show a warning about minimum privilege', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: [], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const warning = wrapper.find(PrivilegeCalloutWarning); + expect(warning).toHaveLength(0); + }); + + it('allows space privileges to be customized', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: [], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(1); + + const addPrivilegeButton = wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]'); + expect(addPrivilegeButton).toHaveLength(1); + }); + }); +}); 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 new file mode 100644 index 00000000000000..af76691b060f59 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx @@ -0,0 +1,347 @@ +/* + * 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 { + EuiButton, + // @ts-ignore + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { KibanaApplicationPrivilege } from '../../../../../../../common/model/kibana_application_privilege'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; +import { copyRole } from '../../../lib/copy_role'; +import { getAvailablePrivileges } from '../../../lib/get_available_privileges'; +import { RoleValidator } from '../../../lib/validate_role'; +import { ImpactedSpacesFlyout } from './impacted_spaces_flyout'; +import { PrivilegeCalloutWarning } from './privilege_callout_warning'; +import { PrivilegeSelector } from './privilege_selector'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeSpaceTable } from './privilege_space_table'; + +interface Props { + kibanaAppPrivileges: KibanaApplicationPrivilege[]; + role: Role; + spaces: Space[]; + onChange: (role: Role) => void; + editable: boolean; + validator: RoleValidator; +} + +interface PrivilegeForm { + spaces: string[]; + privilege: KibanaPrivilege | null; +} + +interface SpacePrivileges { + [spaceId: string]: KibanaPrivilege[]; +} + +interface State { + spacePrivileges: SpacePrivileges; + privilegeForms: PrivilegeForm[]; +} + +export class SpaceAwarePrivilegeForm extends Component { + constructor(props: Props) { + super(props); + const { role } = props; + + const assignedPrivileges = role.kibana; + const spacePrivileges = { + ...assignedPrivileges.space, + }; + + this.state = { + spacePrivileges, + privilegeForms: [], + }; + } + + public render() { + const { kibanaAppPrivileges, role } = this.props; + + const assignedPrivileges = role.kibana; + const availablePrivileges = kibanaAppPrivileges.map(privilege => privilege.name); + + const basePrivilege = + assignedPrivileges.global.length > 0 ? assignedPrivileges.global[0] : NO_PRIVILEGE_VALUE; + + const description = ( +

+ Specifies the lowest permission level for all spaces, unless a custom privilege is + specified. +

+ ); + + let helptext; + if (basePrivilege === NO_PRIVILEGE_VALUE) { + helptext = 'No access'; + } else if (basePrivilege === 'all') { + helptext = 'View, edit, and share all objects and apps within all spaces'; + } else if (basePrivilege === 'read') { + helptext = 'View only mode'; + } + + return ( + + Minimum privilege} description={description}> + + + + + + + + {this.renderSpacePrivileges(basePrivilege, availablePrivileges)} + + ); + } + + public renderSpacePrivileges = ( + basePrivilege: KibanaPrivilege, + availablePrivileges: KibanaPrivilege[] + ) => { + const { role, spaces } = this.props; + + const { spacePrivileges } = this.state; + + const availableSpaces = this.getAvailableSpaces(); + + const canAssignSpacePrivileges = basePrivilege !== 'all'; + const hasAssignedSpacePrivileges = Object.keys(this.state.spacePrivileges).length > 0; + + const showAddPrivilegeButton = + canAssignSpacePrivileges && this.props.editable && availableSpaces.length > 0; + + return ( + + +

Space privileges

+
+ + +

+ Customize permission levels per space. If a space is not customized, its permissions + will default to the minimum privilege specified above. +

+ {basePrivilege !== 'all' && + this.props.editable && ( +

+ You can bulk-create space privileges though they will be saved individually upon + saving the role. +

+ )} +
+ + {(basePrivilege !== NO_PRIVILEGE_VALUE || isReservedRole(this.props.role)) && ( + + )} + + {basePrivilege === 'read' && this.props.editable && } + + {canAssignSpacePrivileges && ( + + + + {hasAssignedSpacePrivileges && } + + {this.getSpaceForms(basePrivilege)} + + )} + + + {showAddPrivilegeButton && ( + + + Add space privilege + + + )} + + + + +
+ ); + }; + + public getSpaceForms = (basePrivilege: KibanaPrivilege) => { + if (!this.props.editable) { + return null; + } + + return this.state.privilegeForms.map((form, index) => + this.getSpaceForm(form, index, basePrivilege) + ); + }; + + public addSpacePrivilege = () => { + this.setState({ + privilegeForms: [ + ...this.state.privilegeForms, + { + spaces: [], + privilege: null, + }, + ], + }); + }; + + public getAvailableSpaces = (omitIndex?: number): Space[] => { + const { spacePrivileges } = this.state; + + return this.props.spaces.filter(space => { + const alreadyAssigned = Object.keys(spacePrivileges).indexOf(space.id) >= 0; + + if (alreadyAssigned) { + return false; + } + + const otherForms = [...this.state.privilegeForms]; + if (typeof omitIndex === 'number') { + otherForms.splice(omitIndex, 1); + } + + const inAnotherForm = otherForms.some(({ spaces }) => spaces.indexOf(space.id) >= 0); + + return !inAnotherForm; + }); + }; + + public getSpaceForm = (form: PrivilegeForm, index: number, basePrivilege: KibanaPrivilege) => { + const { spaces: selectedSpaceIds, privilege } = form; + + const availableSpaces = this.getAvailableSpaces(index); + + return ( + + + + + ); + }; + + public onPrivilegeSpacePermissionChange = (index: number) => (form: PrivilegeForm) => { + const existingPrivilegeForm = { ...this.state.privilegeForms[index] }; + const updatedPrivileges = [...this.state.privilegeForms]; + updatedPrivileges[index] = { + spaces: form.spaces, + privilege: form.privilege, + }; + + this.setState({ + privilegeForms: updatedPrivileges, + }); + + const role = copyRole(this.props.role); + + if (!form.spaces.length || !form.privilege) { + existingPrivilegeForm.spaces.forEach(spaceId => { + role.kibana.space[spaceId] = []; + }); + } else { + const privilege = form.privilege; + if (privilege) { + form.spaces.forEach(spaceId => { + role.kibana.space[spaceId] = [privilege]; + }); + } + } + + this.props.validator.setInProgressSpacePrivileges(updatedPrivileges); + this.props.onChange(role); + }; + + public onPrivilegeSpacePermissionDelete = (index: number) => () => { + const updatedPrivileges = [...this.state.privilegeForms]; + const removedPrivilege = updatedPrivileges.splice(index, 1)[0]; + + this.setState({ + privilegeForms: updatedPrivileges, + }); + + const role = copyRole(this.props.role); + + removedPrivilege.spaces.forEach(spaceId => { + delete role.kibana.space[spaceId]; + }); + + this.props.onChange(role); + }; + + public onExistingSpacePrivilegesChange = (assignedPrivileges: SpacePrivileges) => { + const role = copyRole(this.props.role); + + role.kibana.space = assignedPrivileges; + + this.setState({ + spacePrivileges: assignedPrivileges, + }); + + this.props.onChange(role); + }; + + public onKibanaBasePrivilegeChange = (privilege: KibanaPrivilege) => { + const role = copyRole(this.props.role); + + // Remove base privilege value + role.kibana.global = []; + + if (privilege !== NO_PRIVILEGE_VALUE) { + role.kibana.global = [privilege]; + } + + this.props.onChange(role); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.test.tsx new file mode 100644 index 00000000000000..33579203e2f915 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.test.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { SpaceSelector } from './space_selector'; + +describe('SpaceSelector', () => { + it('renders without crashing', () => { + expect( + shallow() + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.tsx new file mode 100644 index 00000000000000..29b22fc32496f0 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.tsx @@ -0,0 +1,64 @@ +/* + * 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 { + // @ts-ignore + EuiComboBox, + EuiHealth, + // @ts-ignore + EuiHighlight, +} from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { getSpaceColor } from '../../../../../../../../spaces/common/space_attributes'; + +const spaceToOption = (space?: Space) => { + if (!space) { + return; + } + + return { id: space.id, label: space.name, color: getSpaceColor(space) }; +}; + +const spaceIdToOption = (spaces: Space[]) => (s: string) => + spaceToOption(spaces.find(space => space.id === s)); + +interface Props { + spaces: Space[]; + selectedSpaceIds: string[]; + onChange: (spaceIds: string[]) => void; + disabled?: boolean; +} + +export class SpaceSelector extends Component { + public render() { + const renderOption = (option: any, searchValue: string, contentClassName: string) => { + const { color, label } = option; + return ( + + + {label} + + + ); + }; + + return ( + + ); + } + + public onChange = (selectedSpaces: Space[]) => { + this.props.onChange(selectedSpaces.map(s => s.id)); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana_privileges.js deleted file mode 100644 index 37795c4c02575e..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana_privileges.js +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { isReservedRole } from '../../../../../lib/role'; -import { getKibanaPrivilegesViewModel } from '../../lib/get_application_privileges'; - -import { CollapsiblePanel } from '../collapsible_panel'; -import { - EuiSelect, - EuiDescribedFormGroup, - EuiFormRow, -} from '@elastic/eui'; - -const noPrivilegeValue = '-none-'; - -export class KibanaPrivileges extends Component { - static propTypes = { - role: PropTypes.object.isRequired, - spaces: PropTypes.array, - kibanaAppPrivileges: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, - }; - - idPrefix = () => `id_`; - - privilegeToId = (privilege) => `${this.idPrefix()}${privilege}`; - - idToPrivilege = (id) => id.split(this.idPrefix())[1]; - - render() { - return ( - - {this.getForm()} - - ); - } - - getForm = () => { - const { - kibanaAppPrivileges, - role, - } = this.props; - - const kibanaPrivileges = getKibanaPrivilegesViewModel(kibanaAppPrivileges, role.kibana); - - const options = [ - { value: noPrivilegeValue, text: 'none' }, - ...Object.keys(kibanaPrivileges).map(p => ({ - value: p, - text: p - })) - ]; - - const value = Object.keys(kibanaPrivileges).find(p => kibanaPrivileges[p]) || noPrivilegeValue; - - return ( - Application privileges

} - description={

Manage the actions this role can perform within Kibana.

} - > - - - -
- ); - } - - onKibanaPrivilegesChange = (e) => { - const role = { - ...this.props.role, - kibana: { - ...this.props.role.kibana - } - }; - - const privilege = e.target.value; - - if (privilege === noPrivilegeValue) { - // unsetting all privileges -- only necessary until RBAC Phase 3 - role.kibana.global = []; - } else { - role.kibana.global = [privilege]; - } - - this.props.onChange(role); - } -} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.tsx similarity index 59% rename from x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.js rename to x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.tsx index 939348661a7417..91719960583a32 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.tsx @@ -4,22 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiIcon } from '@elastic/eui'; +import { shallow } from 'enzyme'; import React from 'react'; -import { - EuiIcon -} from '@elastic/eui'; +import { Role } from '../../../../../common/model/role'; import { ReservedRoleBadge } from './reserved_role_badge'; -import { - shallow -} from 'enzyme'; -const reservedRole = { +const reservedRole: Role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, metadata: { - _reserved: true - } + _reserved: true, + }, }; -const unreservedRole = {}; +const unreservedRole = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, +}; test('it renders without crashing', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.js b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.tsx similarity index 62% rename from x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.js rename to x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.tsx index 8b750b19338ed4..2966c78d2d92aa 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.js +++ b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.tsx @@ -5,30 +5,24 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; +import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { Role } from '../../../../../common/model/role'; import { isReservedRole } from '../../../../lib/role'; -import { - EuiIcon, - EuiToolTip, -} from '@elastic/eui'; +interface Props { + role: Role; +} -export const ReservedRoleBadge = (props) => { - const { - role - } = props; +export const ReservedRoleBadge = (props: Props) => { + const { role } = props; if (isReservedRole(role)) { return ( - + ); } return null; }; - -ReservedRoleBadge.propTypes = { - role: PropTypes.object.isRequired -}; diff --git a/x-pack/plugins/security/public/views/management/edit_role/edit_role.less b/x-pack/plugins/security/public/views/management/edit_role/edit_role.less index d4f7ac04880d60..b3b4212bb1f1a9 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/edit_role.less +++ b/x-pack/plugins/security/public/views/management/edit_role/edit_role.less @@ -1,10 +1,4 @@ #editRoleReactRoot { background: #f5f5f5; - flex-grow: 1; -} - -.editRolePage { - max-width: 1000px; - margin-left: auto; - margin-right: auto; + min-height: ~"calc(100vh - 70px)"; } 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 765dca06665f6c..5b8b57d6c01013 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 { SpacesManager } from 'plugins/spaces/lib'; import { checkLicenseError } from 'plugins/security/lib/check_license_error'; import { EDIT_ROLES_PATH, ROLES_PATH } from '../management_urls'; @@ -80,10 +81,13 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { indexPatterns(Private) { const indexPatterns = Private(IndexPatternsProvider); return indexPatterns.getTitles(); + }, + spaces($http, chrome) { + return new SpacesManager($http, chrome).getSpaces(); } }, controllerAs: 'editRole', - controller($injector, $scope, $http) { + controller($injector, $scope, $http, enableSpaceAwarePrivileges) { const $route = $injector.get('$route'); const Private = $injector.get('Private'); @@ -97,9 +101,29 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { const allowFieldLevelSecurity = xpackInfo.get('features.security.allowRoleFieldLevelSecurity'); const rbacApplication = chrome.getInjected('rbacApplication'); + if (role.elasticsearch.indices.length === 0) { + const emptyOption = { + names: [], + privileges: [] + }; + + if (allowFieldLevelSecurity) { + emptyOption.field_security = { + grant: ['*'] + }; + } + + if (allowDocumentLevelSecurity) { + emptyOption.query = ''; + } + + role.elasticsearch.indices.push(emptyOption); + } + const { users, indexPatterns, + spaces, } = $route.current.locals; $scope.$$postDigest(() => { @@ -116,6 +140,8 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { allowDocumentLevelSecurity={allowDocumentLevelSecurity} allowFieldLevelSecurity={allowFieldLevelSecurity} notifier={Notifier} + spaces={spaces} + spacesEnabled={enableSpaceAwarePrivileges} />, domNode); // unmount react on controller destroy diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/get_available_privileges.test.ts.snap b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/get_available_privileges.test.ts.snap new file mode 100644 index 00000000000000..177ffc17078363 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/get_available_privileges.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getAvailablePrivileges throws when given an unexpected minimum privilege 1`] = `"Unexpected minimumPrivilege value: idk"`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.ts.snap similarity index 100% rename from x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.js.snap rename to x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.ts.snap diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/constants.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/constants.ts new file mode 100644 index 00000000000000..7378251fc84ac5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/constants.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/*../../../../../common/model/kibana_privilege + * 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 { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const NO_PRIVILEGE_VALUE: KibanaPrivilege = 'none'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.test.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.test.ts new file mode 100644 index 00000000000000..992c67ba85e73b --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { Role } from '../../../../../common/model/role'; +import { copyRole } from './copy_role'; + +describe('copyRole', () => { + it('should perform a deep copy', () => { + const role: Role = { + elasticsearch: { + cluster: ['all'], + indices: [{ names: ['index*'], privileges: ['all'] }], + run_as: ['user'], + }, + kibana: { + global: ['read'], + space: { + marketing: ['all'], + }, + }, + }; + + const result = copyRole(role); + expect(result).toEqual(role); + + role.elasticsearch.indices[0].names = ['something else']; + + expect(result).not.toEqual(role); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts new file mode 100644 index 00000000000000..395f14756c5470 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep } from 'lodash'; +import { Role } from '../../../../../common/model/role'; + +export function copyRole(role: Role) { + return cloneDeep(role); +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/get_application_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/lib/get_application_privileges.js deleted file mode 100644 index 138fbaff51d921..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role/lib/get_application_privileges.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -export function getKibanaPrivilegesViewModel(applicationPrivileges, roleKibanaPrivileges) { - const viewModel = applicationPrivileges.reduce((acc, applicationPrivilege) => { - acc[applicationPrivilege.name] = false; - return acc; - }, {}); - - if (!roleKibanaPrivileges || roleKibanaPrivileges.length === 0) { - return viewModel; - } - - const assignedPrivileges = roleKibanaPrivileges.global; - assignedPrivileges.forEach(assignedPrivilege => { - // we don't want to display privileges that aren't in our expected list of privileges - if (assignedPrivilege in viewModel) { - viewModel[assignedPrivilege] = true; - } - }); - - return viewModel; -} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.test.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.test.ts new file mode 100644 index 00000000000000..3f7e8b13308126 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.test.ts @@ -0,0 +1,27 @@ +/* + * 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 { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from './constants'; +import { getAvailablePrivileges } from './get_available_privileges'; + +describe('getAvailablePrivileges', () => { + it('throws when given an unexpected minimum privilege', () => { + expect(() => getAvailablePrivileges('idk' as KibanaPrivilege)).toThrowErrorMatchingSnapshot(); + }); + + it(`returns all privileges when the minimum privilege is none`, () => { + expect(getAvailablePrivileges(NO_PRIVILEGE_VALUE)).toEqual(['read', 'all']); + }); + + it(`returns all privileges when the minimum privilege is read`, () => { + expect(getAvailablePrivileges('read')).toEqual(['read', 'all']); + }); + + it(`returns just the "all" privilege when the minimum privilege is all`, () => { + expect(getAvailablePrivileges('all')).toEqual(['all']); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.ts new file mode 100644 index 00000000000000..89b6ff6cd8d80a --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.ts @@ -0,0 +1,21 @@ +/* + * 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 { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from './constants'; + +export function getAvailablePrivileges(minimumPrivilege: KibanaPrivilege): KibanaPrivilege[] { + switch (minimumPrivilege) { + case NO_PRIVILEGE_VALUE: + return ['read', 'all']; + case 'read': + return ['read', 'all']; + case 'all': + return ['all']; + default: + throw new Error(`Unexpected minimumPrivilege value: ${minimumPrivilege}`); + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/set_application_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/lib/set_application_privileges.js deleted file mode 100644 index d900c13743a14c..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role/lib/set_application_privileges.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ALL_RESOURCE } from '../../../../../common/constants'; - -export function setApplicationPrivileges(kibanaPrivileges, role, application) { - if (!role.applications) { - role.applications = []; - } - - // we first remove the matching application entries - role.applications = role.applications.filter(x => { - return x.application !== application; - }); - - const privileges = Object.keys(kibanaPrivileges).filter(key => kibanaPrivileges[key]); - - // if we still have them, put the application entry back - if (privileges.length > 0) { - role.applications = [...role.applications, { - application, - privileges, - resources: [ALL_RESOURCE] - }]; - } -} - -export function togglePrivilege(role, application, permission) { - const appPermissions = role.applications - .find(a => a.application === application && a.resources[0] === ALL_RESOURCE); - - if (!appPermissions) { - role.applications.push({ - application, - privileges: [permission], - resources: [ALL_RESOURCE] - }); - } else { - const indexOfExisting = appPermissions.privileges.indexOf(permission); - if (indexOfExisting >= 0) { - appPermissions.privileges.splice(indexOfExisting, 1); - } else { - appPermissions.privileges.push(permission); - } - } -} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.js b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.js deleted file mode 100644 index 29160173363b46..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.js +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export class RoleValidator { - constructor(options = {}) { - this._shouldValidate = options.shouldValidate; - } - - enableValidation() { - this._shouldValidate = true; - } - - disableValidation() { - this._shouldValidate = false; - } - - validateRoleName(role) { - if (!this._shouldValidate) return valid(); - - if (!role.name) { - return invalid(`Please provide a role name`); - } - if (role.name.length > 1024) { - return invalid(`Name must not exceed 1024 characters`); - } - if (!role.name.match(/^[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*$/)) { - return invalid(`Name must begin with a letter or underscore and contain only letters, underscores, and numbers.`); - } - return valid(); - } - - validateIndexPrivileges(role) { - if (!this._shouldValidate) return valid(); - - if (!Array.isArray(role.elasticsearch.indices)) { - throw new TypeError(`Expected role.elasticsearch.indices to be an array`); - } - - const areIndicesValid = role.elasticsearch.indices - .map(this.validateIndexPrivilege.bind(this)) - .find((result) => result.isInvalid) == null; - - if (areIndicesValid) { - return valid(); - } - return invalid(); - } - - validateIndexPrivilege(indexPrivilege) { - if (!this._shouldValidate) return valid(); - - if (indexPrivilege.names.length && !indexPrivilege.privileges.length) { - return invalid(`At least one privilege is required`); - } - return valid(); - } - - validateForSave(role) { - const { isInvalid: isNameInvalid } = this.validateRoleName(role); - const { isInvalid: areIndicesInvalid } = this.validateIndexPrivileges(role); - - if (isNameInvalid || areIndicesInvalid) { - return invalid(); - } - - return valid(); - } - -} - -function invalid(error) { - return { - isInvalid: true, - error - }; -} - -function valid() { - return { - isInvalid: false - }; -} - diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.js b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.js deleted file mode 100644 index 75fc8341fc639c..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.js +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { RoleValidator } from "./validate_role"; - -let validator; - -describe('validateRoleName', () => { - beforeEach(() => { - validator = new RoleValidator({ shouldValidate: true }); - }); - - test('it allows an alphanumeric role name', () => { - const role = { - name: 'This-is-30-character-role-name' - }; - - expect(validator.validateRoleName(role)).toEqual({ isInvalid: false }); - }); - - test('it requires a non-empty value', () => { - const role = { - name: '' - }; - - expect(validator.validateRoleName(role)).toEqual({ isInvalid: true, error: `Please provide a role name` }); - }); - - test('it cannot exceed 1024 characters', () => { - const role = { - name: new Array(1026).join('A') - }; - - expect(validator.validateRoleName(role)).toEqual({ isInvalid: true, error: `Name must not exceed 1024 characters` }); - }); - - const charList = `!#%^&*()+=[]{}\|';:"/,<>?`.split(''); - charList.forEach(element => { - test(`it cannot support the "${element}" character`, () => { - const role = { - name: `role-${element}` - }; - - expect(validator.validateRoleName(role)).toEqual( - { - isInvalid: true, - error: `Name must begin with a letter or underscore and contain only letters, underscores, and numbers.` - } - ); - }); - }); -}); - -describe('validateIndexPrivileges', () => { - beforeEach(() => { - validator = new RoleValidator({ shouldValidate: true }); - }); - - test('it ignores privilegs with no indices defined', () => { - const role = { - elasticsearch: { - indices: [{ - names: [], - privileges: [] - }] - } - - }; - - expect(validator.validateIndexPrivileges(role)).toEqual({ - isInvalid: false - }); - }); - - test('it requires privilges when an index is defined', () => { - const role = { - elasticsearch: { - indices: [{ - names: ['index-*'], - privileges: [] - }] - } - - }; - - expect(validator.validateIndexPrivileges(role)).toEqual({ - isInvalid: true - }); - }); - - test('it throws when indices is not an array', () => { - const role = { - elasticsearch: { - indices: null - } - }; - - expect(() => validator.validateIndexPrivileges(role)).toThrowErrorMatchingSnapshot(); - }); -}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.ts new file mode 100644 index 00000000000000..b6f6b4234335d5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.ts @@ -0,0 +1,397 @@ +/* + * 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 { Role } from '../../../../../common/model/role'; +import { RoleValidator } from './validate_role'; + +let validator: RoleValidator; + +describe('validateRoleName', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + test('it allows an alphanumeric role name', () => { + const role: Role = { + name: 'This-is-30-character-role-name', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ + isInvalid: true, + error: `Please provide a role name`, + }); + }); + + test('it cannot exceed 1024 characters', () => { + const role = { + name: new Array(1026).join('A'), + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ + isInvalid: true, + error: `Name must not exceed 1024 characters`, + }); + }); + + const charList = `!#%^&*()+=[]{}\|';:"/,<>?`.split(''); + charList.forEach(element => { + test(`it cannot support the "${element}" character`, () => { + const role = { + name: `role-${element}`, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ + isInvalid: true, + error: `Name must begin with a letter or underscore and contain only letters, underscores, and numbers.`, + }); + }); + }); +}); + +describe('validateIndexPrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + test('it ignores privilegs with no indices defined', () => { + const role = { + name: '', + elasticsearch: { + indices: [ + { + names: [], + privileges: [], + }, + ], + cluster: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateIndexPrivileges(role)).toEqual({ + isInvalid: false, + }); + }); + + test('it requires privilges when an index is defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [ + { + names: ['index-*'], + privileges: [], + }, + ], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateIndexPrivileges(role)).toEqual({ + isInvalid: true, + }); + }); + + test('it throws when indices is not an array', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: 'asdf', + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + // @ts-ignore + expect(() => validator.validateIndexPrivileges(role)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('validateInProgressSpacePrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + it('should validate when both spaces and privilege is unassigned', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([{}, {}]); + expect(validator.validateInProgressSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should invalidate when spaces are not assigned to a privilege', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + privilege: 'all', + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toMatchObject({ + isInvalid: true, + }); + }); + + it('should invalidate when a privilege is not assigned to a space', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toMatchObject({ + isInvalid: true, + }); + }); + + it('should validate when a privilege is assigned to a space', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + privilege: 'all', + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toEqual({ + isInvalid: false, + }); + }); + + it('should skip validation if the global privilege is set to "all"', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toMatchObject({ + isInvalid: false, + }); + }); +}); + +describe('validateSpacePrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + it('should validate when no privileges are defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should validate when a global privilege is defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: {}, + }, + }; + + expect(validator.validateSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should validate when a space privilege is defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: { + marketing: ['read'], + }, + }, + }; + + expect(validator.validateSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should validate when both global and space privileges are defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: { + default: ['foo'], + marketing: ['read'], + }, + }, + }; + + expect(validator.validateSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should invalidate when in-progress space privileges are not valid', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['read'], + space: { + default: ['foo'], + marketing: ['read'], + }, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + }, + ]); + + expect(validator.validateSpacePrivileges(role)).toEqual({ isInvalid: true }); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.ts new file mode 100644 index 00000000000000..4e8755cfae92c5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.ts @@ -0,0 +1,205 @@ +/* + * 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. + */ + +/* + * Copyright Elasticsearch B.V. ../../../../../common/model/index_privileger 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 { IndexPrivilege } from '../../../../../common/model/IndexPrivilege'; +import { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../common/model/role'; + +/* + * 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 RoleValidatorOptions { + shouldValidate?: boolean; +} + +export interface RoleValidationResult { + isInvalid: boolean; + error?: string; +} + +export class RoleValidator { + private shouldValidate?: boolean; + + private inProgressSpacePrivileges: any[] = []; + + constructor(options: RoleValidatorOptions = {}) { + this.shouldValidate = options.shouldValidate; + } + + public enableValidation() { + this.shouldValidate = true; + } + + public disableValidation() { + this.shouldValidate = false; + } + + public validateRoleName(role: Role): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (!role.name) { + return invalid(`Please provide a role name`); + } + if (role.name.length > 1024) { + return invalid(`Name must not exceed 1024 characters`); + } + if (!role.name.match(/^[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*$/)) { + return invalid( + `Name must begin with a letter or underscore and contain only letters, underscores, and numbers.` + ); + } + return valid(); + } + + public validateIndexPrivileges(role: Role): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (!Array.isArray(role.elasticsearch.indices)) { + throw new TypeError(`Expected role.elasticsearch.indices to be an array`); + } + + const areIndicesValid = + role.elasticsearch.indices + .map(indexPriv => this.validateIndexPrivilege(indexPriv)) + .find((result: RoleValidationResult) => result.isInvalid) == null; + + if (areIndicesValid) { + return valid(); + } + return invalid(); + } + + public validateIndexPrivilege(indexPrivilege: IndexPrivilege): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (indexPrivilege.names.length && !indexPrivilege.privileges.length) { + return invalid(`At least one privilege is required`); + } + return valid(); + } + + public validateSelectedSpaces( + spaceIds: string[], + privilege: KibanaPrivilege | null + ): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + // If no assigned privilege, then no spaces are OK + if (!privilege) { + return valid(); + } + + if (Array.isArray(spaceIds) && spaceIds.length > 0) { + return valid(); + } + return invalid('At least one space is required'); + } + + public validateSelectedPrivilege( + spaceIds: string[], + privilege: KibanaPrivilege | null + ): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + // If no assigned spaces, then a missing privilege is OK + if (!spaceIds || spaceIds.length === 0) { + return valid(); + } + + if (privilege) { + return valid(); + } + return invalid('Privilege is required'); + } + + public setInProgressSpacePrivileges(inProgressSpacePrivileges: any[]) { + this.inProgressSpacePrivileges = [...inProgressSpacePrivileges]; + } + + public validateInProgressSpacePrivileges(role: Role): RoleValidationResult { + const { global } = role.kibana; + + // A Global privilege of "all" will ignore all in progress privileges, + // so the form should not block saving in this scenario. + const shouldValidate = this.shouldValidate && !global.includes('all'); + + if (!shouldValidate) { + return valid(); + } + + const allInProgressValid = this.inProgressSpacePrivileges.every(({ spaces, privilege }) => { + return ( + !this.validateSelectedSpaces(spaces, privilege).isInvalid && + !this.validateSelectedPrivilege(spaces, privilege).isInvalid + ); + }); + + if (allInProgressValid) { + return valid(); + } + return invalid(); + } + + public validateSpacePrivileges(role: Role): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + const privileges = Object.values(role.kibana.space || {}); + + const arePrivilegesValid = privileges.every(assignedPrivilege => !!assignedPrivilege); + const areInProgressPrivilegesValid = !this.validateInProgressSpacePrivileges(role).isInvalid; + + if (arePrivilegesValid && areInProgressPrivilegesValid) { + return valid(); + } + return invalid(); + } + + public validateForSave(role: Role): RoleValidationResult { + const { isInvalid: isNameInvalid } = this.validateRoleName(role); + const { isInvalid: areIndicesInvalid } = this.validateIndexPrivileges(role); + const { isInvalid: areSpacePrivilegesInvalid } = this.validateSpacePrivileges(role); + + if (isNameInvalid || areIndicesInvalid || areSpacePrivilegesInvalid) { + return invalid(); + } + + return valid(); + } +} + +function invalid(error?: string): RoleValidationResult { + return { + isInvalid: true, + error, + }; +} + +function valid(): RoleValidationResult { + return { + isInvalid: false, + }; +} diff --git a/x-pack/plugins/security/public/views/management/management_urls.js b/x-pack/plugins/security/public/views/management/management_urls.ts similarity index 100% rename from x-pack/plugins/security/public/views/management/management_urls.js rename to x-pack/plugins/security/public/views/management/management_urls.ts diff --git a/x-pack/plugins/security/server/routes/api/public/roles/get.js b/x-pack/plugins/security/server/routes/api/public/roles/get.js index 88d096fda39063..a0c7fd0ec4f39c 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/get.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/get.js @@ -5,8 +5,8 @@ */ import _ from 'lodash'; import Boom from 'boom'; -import { ALL_RESOURCE } from '../../../../../common/constants'; import { wrapError } from '../../../../lib/errors'; +import { ALL_RESOURCE } from '../../../../../common/constants'; export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) { @@ -91,7 +91,7 @@ export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, } return reply(Boom.notFound()); - } catch(error) { + } catch (error) { reply(wrapError(error)); } }, diff --git a/x-pack/plugins/security/server/routes/api/public/roles/put.js b/x-pack/plugins/security/server/routes/api/public/roles/put.js index 5c464bbe9fd54c..262e2259cbea3d 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/put.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/put.js @@ -6,8 +6,8 @@ import { pick, identity } from 'lodash'; import Joi from 'joi'; -import { ALL_RESOURCE } from '../../../../../common/constants'; import { wrapError } from '../../../../lib/errors'; +import { ALL_RESOURCE } from '../../../../../common/constants'; const transformKibanaPrivilegesToEs = (application, kibanaPrivileges) => { const kibanaApplicationPrivileges = []; @@ -20,7 +20,7 @@ const transformKibanaPrivilegesToEs = (application, kibanaPrivileges) => { } if (kibanaPrivileges.space) { - for(const [spaceId, privileges] of Object.entries(kibanaPrivileges.space)) { + for (const [spaceId, privileges] of Object.entries(kibanaPrivileges.space)) { kibanaApplicationPrivileges.push({ privileges: privileges, application, diff --git a/x-pack/plugins/security/server/routes/api/public/roles/put.test.js b/x-pack/plugins/security/server/routes/api/public/roles/put.test.js index 67ebe5ae017a06..d159c98c59eb4d 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/put.test.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/put.test.js @@ -155,7 +155,7 @@ describe('PUT role', () => { name: 'foo-role', payload: {}, preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => {}], + callWithRequestImpls: [async () => ({}), async () => { }], asserts: { callWithRequests: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], @@ -189,7 +189,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: ['test-index-privilege-1', 'test-index-privilege-2'], @@ -207,7 +207,7 @@ describe('PUT role', () => { }, }, preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => {}], + callWithRequestImpls: [async () => ({}), async () => { }], asserts: { callWithRequests: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], @@ -247,7 +247,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: [ @@ -280,7 +280,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: ['test-index-privilege-1', 'test-index-privilege-2'], @@ -312,7 +312,7 @@ describe('PUT role', () => { { field_security: { grant: ['old-field-security-grant-1', 'old-field-security-grant-2'], - except: [ 'old-field-security-except-1', 'old-field-security-except-2' ] + except: ['old-field-security-except-1', 'old-field-security-except-2'] }, names: ['old-index-name'], privileges: ['old-privilege'], @@ -329,7 +329,7 @@ describe('PUT role', () => { ], }, }), - async () => {}, + async () => { }, ], asserts: { callWithRequests: [ @@ -370,7 +370,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: [ @@ -457,7 +457,7 @@ describe('PUT role', () => { ], }, }), - async () => {}, + async () => { }, ], asserts: { callWithRequests: [ diff --git a/x-pack/plugins/spaces/public/views/components/__snapshots__/space_avatar.test.js.snap b/x-pack/plugins/spaces/public/components/__snapshots__/space_avatar.test.js.snap similarity index 100% rename from x-pack/plugins/spaces/public/views/components/__snapshots__/space_avatar.test.js.snap rename to x-pack/plugins/spaces/public/components/__snapshots__/space_avatar.test.js.snap diff --git a/x-pack/plugins/spaces/public/components/index.ts b/x-pack/plugins/spaces/public/components/index.ts new file mode 100644 index 00000000000000..2e73f0c704f8c5 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { SpaceAvatar } from './space_avatar'; +export { ManageSpacesButton } from './manage_spaces_button'; diff --git a/x-pack/plugins/spaces/public/views/components/manage_spaces_button.tsx b/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx similarity index 94% rename from x-pack/plugins/spaces/public/views/components/manage_spaces_button.tsx rename to x-pack/plugins/spaces/public/components/manage_spaces_button.tsx index 42c11948aecd30..55b14b856ef42a 100644 --- a/x-pack/plugins/spaces/public/views/components/manage_spaces_button.tsx +++ b/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx @@ -6,7 +6,7 @@ import { EuiButton } from '@elastic/eui'; import React, { Component, CSSProperties } from 'react'; -import { MANAGE_SPACES_URL } from '../../lib/constants'; +import { MANAGE_SPACES_URL } from '../lib/constants'; interface Props { isDisabled?: boolean; diff --git a/x-pack/plugins/spaces/public/views/components/space_avatar.test.js b/x-pack/plugins/spaces/public/components/space_avatar.test.js similarity index 100% rename from x-pack/plugins/spaces/public/views/components/space_avatar.test.js rename to x-pack/plugins/spaces/public/components/space_avatar.test.js diff --git a/x-pack/plugins/spaces/public/views/components/space_avatar.tsx b/x-pack/plugins/spaces/public/components/space_avatar.tsx similarity index 91% rename from x-pack/plugins/spaces/public/views/components/space_avatar.tsx rename to x-pack/plugins/spaces/public/components/space_avatar.tsx index 5e78a5e32b7f18..571cc083cb868d 100644 --- a/x-pack/plugins/spaces/public/views/components/space_avatar.tsx +++ b/x-pack/plugins/spaces/public/components/space_avatar.tsx @@ -6,8 +6,8 @@ import { EuiAvatar } from '@elastic/eui'; import React from 'react'; -import { getSpaceColor, getSpaceInitials, MAX_SPACE_INITIALS } from '../../../common'; -import { Space } from '../../../common/model/space'; +import { getSpaceColor, getSpaceInitials, MAX_SPACE_INITIALS } from '../../common'; +import { Space } from '../../common/model/space'; interface Props { space: Space; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.js b/x-pack/plugins/spaces/public/lib/index.js similarity index 65% rename from x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.js rename to x-pack/plugins/spaces/public/lib/index.js index d142af394b1555..0a22964efbca39 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.js +++ b/x-pack/plugins/spaces/public/lib/index.js @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ElasticsearchPrivileges } from './elasticsearch_privileges'; -export { KibanaPrivileges } from './kibana_privileges'; +export { SpacesManager } from './spaces_manager'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/components/index.ts b/x-pack/plugins/spaces/public/views/components/index.ts index cb41cc6e31e0d8..9a6f95a3d9ed91 100644 --- a/x-pack/plugins/spaces/public/views/components/index.ts +++ b/x-pack/plugins/spaces/public/views/components/index.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SpaceAvatar } from './space_avatar'; export { SpaceCards } from './space_cards'; -export { ManageSpacesButton } from './manage_spaces_button'; diff --git a/x-pack/plugins/spaces/public/views/components/space_card.tsx b/x-pack/plugins/spaces/public/views/components/space_card.tsx index 07d4464df8e047..9e78687286a7d2 100644 --- a/x-pack/plugins/spaces/public/views/components/space_card.tsx +++ b/x-pack/plugins/spaces/public/views/components/space_card.tsx @@ -14,7 +14,7 @@ import { } from '@elastic/eui'; import React from 'react'; import { Space } from '../../../common/model/space'; -import { SpaceAvatar } from './space_avatar'; +import { SpaceAvatar } from '../../components'; import './space_card.less'; interface Props { 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 8e4da3e399541c..606c7d9ad1c7a2 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 @@ -25,7 +25,7 @@ import { } from '@elastic/eui'; import { DeleteSpacesButton } from '../components'; -import { SpaceAvatar } from '../../components'; +import { SpaceAvatar } from '../../../components'; import { Notifier, toastNotifications } from 'ui/notify'; import { SpaceIdentifier } from './space_identifier'; 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 4f700113437dcc..d85a9e82c7082a 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,7 +6,7 @@ import { EuiContextMenuPanel, EuiText } from '@elastic/eui'; import React, { SFC } from 'react'; -import { ManageSpacesButton } from '../../components'; +import { ManageSpacesButton } from '../../../components'; import './spaces_description.less'; export const SpacesDescription: SFC = () => { 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 b5c805c8e2782f..2376914ec1b114 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 @@ -8,7 +8,7 @@ import { EuiContextMenuItem, EuiContextMenuPanel, EuiFieldSearch, EuiText } from import React, { Component } from 'react'; import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../common/constants'; import { Space } from '../../../../common/model/space'; -import { ManageSpacesButton, SpaceAvatar } from '../../components'; +import { ManageSpacesButton, SpaceAvatar } from '../../../components'; import './spaces_menu.less'; interface Props { 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 09dd22d0b1ac47..0c35f175d930d9 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 @@ -8,7 +8,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { NavControlPopover } from './nav_control_popover'; import { SpacesManager } from '../../lib/spaces_manager'; -import { SpaceAvatar } from '../components/space_avatar'; +import { SpaceAvatar } from '../../components'; const mockChrome = { addBasePath: jest.fn((a) => a) 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 1345d3013000ea..ea7a2d0ca4465c 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 @@ -7,8 +7,8 @@ import { EuiAvatar, EuiPopover } from '@elastic/eui'; import React, { Component } from 'react'; import { Space } from '../../../common/model/space'; +import { SpaceAvatar } from '../../components'; import { SpacesManager } from '../../lib/spaces_manager'; -import { SpaceAvatar } from '../components'; import { SpacesDescription } from './components/spaces_description'; import { SpacesMenu } from './components/spaces_menu'; diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js index ad0bab30ccc90f..5d250c1c92c07a 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js @@ -123,7 +123,8 @@ export function initSpacesApi(server) { return reply(wrapError(error)); } - return reply(convertSavedObjectToSpace(result)); + const updatedSpace = convertSavedObjectToSpace(result); + return reply(updatedSpace); }, config: { validate: { diff --git a/x-pack/test/functional/apps/security/doc_level_security_roles.js b/x-pack/test/functional/apps/security/doc_level_security_roles.js index 9a44d595367d4f..535a0c2c4164a8 100644 --- a/x-pack/test/functional/apps/security/doc_level_security_roles.js +++ b/x-pack/test/functional/apps/security/doc_level_security_roles.js @@ -34,12 +34,16 @@ export default function ({ getService, getPageObjects }) { it('should add new role myroleEast', async function () { await PageObjects.security.addRole('myroleEast', { - - "indices": [{ - "names": [ "dlstest" ], - "privileges": [ "read", "view_index_metadata" ], - "query": "{\"match\": {\"region\": \"EAST\"}}" - }] + elasticsearch: { + "indices": [{ + "names": ["dlstest"], + "privileges": ["read", "view_index_metadata"], + "query": "{\"match\": {\"region\": \"EAST\"}}" + }] + }, + kibana: { + global: ['all'] + } }); const roles = indexBy(await PageObjects.security.getElasticsearchRoles(), 'rolename'); log.debug('actualRoles = %j', roles); @@ -50,9 +54,11 @@ export default function ({ getService, getPageObjects }) { it('should add new user userEAST ', async function () { await PageObjects.security.clickElasticsearchUsers(); - await PageObjects.security.addUser({ username: 'userEast', password: 'changeme', + await PageObjects.security.addUser({ + username: 'userEast', password: 'changeme', confirmPassword: 'changeme', fullname: 'dls EAST', - email: 'dlstest@elastic.com', save: true, roles: ['kibana_user', 'myroleEast'] }); + email: 'dlstest@elastic.com', save: true, roles: ['kibana_user', 'myroleEast'] + }); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); expect(users.userEast.roles).to.eql(['kibana_user', 'myroleEast']); diff --git a/x-pack/test/functional/apps/security/field_level_security.js b/x-pack/test/functional/apps/security/field_level_security.js index 89f5416135c578..a0fc042f58540c 100644 --- a/x-pack/test/functional/apps/security/field_level_security.js +++ b/x-pack/test/functional/apps/security/field_level_security.js @@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const PageObjects = getPageObjects(['security', 'settings', 'common', 'discover', 'header']); - describe('field_level_security', () => { + describe('field_level_security', () => { before('initialize tests', async () => { await esArchiver.loadIfNeeded('security/flstest'); await esArchiver.load('empty_kibana'); @@ -28,11 +28,16 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('viewssnrole', { - "indices": [{ - "names": [ "flstest" ], - "privileges": [ "read", "view_index_metadata" ], - "field_security": { "grant": ["customer_ssn", "customer_name", "customer_region", "customer_type"] } - }] + elasticsearch: { + "indices": [{ + "names": ["flstest"], + "privileges": ["read", "view_index_metadata"], + "field_security": { "grant": ["customer_ssn", "customer_name", "customer_region", "customer_type"] } + }] + }, + kibana: { + global: ['all'] + } }); await PageObjects.common.sleep(1000); @@ -44,11 +49,16 @@ export default function ({ getService, getPageObjects }) { it('should add new role view_no_ssn_role', async function () { await PageObjects.security.addRole('view_no_ssn_role', { - "indices": [{ - "names": [ "flstest" ], - "privileges": [ "read", "view_index_metadata" ], - "field_security": { "grant": ["customer_name", "customer_region", "customer_type"] } - }] + elasticsearch: { + "indices": [{ + "names": ["flstest"], + "privileges": ["read", "view_index_metadata"], + "field_security": { "grant": ["customer_name", "customer_region", "customer_type"] } + }] + }, + kibana: { + global: ['all'] + } }); await PageObjects.common.sleep(1000); const roles = indexBy(await PageObjects.security.getElasticsearchRoles(), 'rolename'); @@ -59,9 +69,11 @@ export default function ({ getService, getPageObjects }) { it('should add new user customer1 ', async function () { await PageObjects.security.clickElasticsearchUsers(); - await PageObjects.security.addUser({ username: 'customer1', password: 'changeme', + await PageObjects.security.addUser({ + username: 'customer1', password: 'changeme', confirmPassword: 'changeme', fullname: 'customer one', email: 'flstest@elastic.com', save: true, - roles: ['kibana_user', 'viewssnrole'] }); + roles: ['kibana_user', 'viewssnrole'] + }); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); expect(users.customer1.roles).to.eql(['kibana_user', 'viewssnrole']); @@ -69,9 +81,11 @@ export default function ({ getService, getPageObjects }) { it('should add new user customer2 ', async function () { await PageObjects.security.clickElasticsearchUsers(); - await PageObjects.security.addUser({ username: 'customer2', password: 'changeme', + await PageObjects.security.addUser({ + username: 'customer2', password: 'changeme', confirmPassword: 'changeme', fullname: 'customer two', email: 'flstest@elastic.com', save: true, - roles: ['kibana_user', 'view_no_ssn_role'] }); + roles: ['kibana_user', 'view_no_ssn_role'] + }); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); expect(users.customer2.roles).to.eql(['kibana_user', 'view_no_ssn_role']); diff --git a/x-pack/test/functional/apps/security/management.js b/x-pack/test/functional/apps/security/management.js index 7b87cd4987a9b4..52821744f76652 100644 --- a/x-pack/test/functional/apps/security/management.js +++ b/x-pack/test/functional/apps/security/management.js @@ -16,8 +16,6 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const remote = getService('remote'); - const retry = getService('retry'); - const find = getService('find'); const PageObjects = getPageObjects(['security', 'settings', 'common', 'header']); describe('Management', () => { @@ -146,23 +144,6 @@ export default function ({ getService, getPageObjects }) { const currentUrl = await remote.getCurrentUrl(); expect(currentUrl).to.contain(EDIT_ROLES_PATH); }); - - it('Reserved roles are not editable', async () => { - // wait for role tab to finish loading from previous test - await PageObjects.header.waitUntilLoadingHasFinished(); - - const allInputs = await find.allByCssSelector('input'); - for (let i = 0; i < allInputs.length; i++) { - const input = allInputs[i]; - // Angular can take a little bit to set the input to disabled, - // so this accounts for that delay - retry.try(async () => { - if (!(await input.getProperty('disabled'))) { - throw new Error('input is not disabled'); - } - }); - } - }); }); }); }); diff --git a/x-pack/test/functional/apps/security/rbac_phase1.js b/x-pack/test/functional/apps/security/rbac_phase1.js index 4b444cfbb79dd5..bc1b578ac2b10a 100644 --- a/x-pack/test/functional/apps/security/rbac_phase1.js +++ b/x-pack/test/functional/apps/security/rbac_phase1.js @@ -25,27 +25,37 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('rbac_all', { - "kibana": ["all"], - "indices": [{ - "names": [ "logstash-*" ], - "privileges": [ "read", "view_index_metadata" ] - }] + kibana: { + global: ['all'] + }, + elasticsearch: { + "indices": [{ + "names": ["logstash-*"], + "privileges": ["read", "view_index_metadata"] + }] + } }); await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('rbac_read', { - "kibana": ["read"], - "indices": [{ - "names": [ "logstash-*" ], - "privileges": [ "read", "view_index_metadata" ] - }] + kibana: { + global: ['read'] + }, + elasticsearch: { + "indices": [{ + "names": ["logstash-*"], + "privileges": ["read", "view_index_metadata"] + }] + } }); await PageObjects.security.clickElasticsearchUsers(); log.debug('After Add user new: , userObj.userName'); - await PageObjects.security.addUser({ username: 'kibanauser', password: 'changeme', + await PageObjects.security.addUser({ + username: 'kibanauser', password: 'changeme', confirmPassword: 'changeme', fullname: 'kibanafirst kibanalast', email: 'kibanauser@myEmail.com', save: true, - roles: ['rbac_all'] }); + roles: ['rbac_all'] + }); log.debug('After Add user: , userObj.userName'); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); @@ -55,10 +65,12 @@ export default function ({ getService, getPageObjects }) { expect(users.kibanauser.reserved).to.be(false); await PageObjects.security.clickElasticsearchUsers(); log.debug('After Add user new: , userObj.userName'); - await PageObjects.security.addUser({ username: 'kibanareadonly', password: 'changeme', + await PageObjects.security.addUser({ + username: 'kibanareadonly', password: 'changeme', confirmPassword: 'changeme', fullname: 'kibanareadonlyFirst kibanareadonlyLast', email: 'kibanareadonly@myEmail.com', save: true, - roles: ['rbac_read'] }); + roles: ['rbac_read'] + }); log.debug('After Add user: , userObj.userName'); const users1 = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); const user = users1.kibanareadonly; diff --git a/x-pack/test/functional/apps/security/secure_roles_perm.js b/x-pack/test/functional/apps/security/secure_roles_perm.js index 5a693e74b9327a..01be29d7f32d6b 100644 --- a/x-pack/test/functional/apps/security/secure_roles_perm.js +++ b/x-pack/test/functional/apps/security/secure_roles_perm.js @@ -32,20 +32,27 @@ export default function ({ getService, getPageObjects }) { it('should add new role logstash_reader', async function () { await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('logstash_reader', { - "indices": [{ - "names": [ "logstash-*" ], - "privileges": [ "read", "view_index_metadata" ] - }] + elasticsearch: { + "indices": [{ + "names": ["logstash-*"], + "privileges": ["read", "view_index_metadata"] + }] + }, + kibana: { + global: ['all'] + } }); }); it('should add new user', async function () { await PageObjects.security.clickElasticsearchUsers(); log.debug('After Add user new: , userObj.userName'); - await PageObjects.security.addUser({ username: 'Rashmi', password: 'changeme', + await PageObjects.security.addUser({ + username: 'Rashmi', password: 'changeme', confirmPassword: 'changeme', fullname: 'RashmiFirst RashmiLast', email: 'rashmi@myEmail.com', save: true, - roles: ['logstash_reader', 'kibana_user'] }); + roles: ['logstash_reader', 'kibana_user'] + }); log.debug('After Add user: , userObj.userName'); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); diff --git a/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz b/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz index 583406fa4da4fa..00e55c17876e9c 100644 Binary files a/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz and b/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json b/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json index d8899a4dedb278..890f4be575fae6 100644 --- a/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json +++ b/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json @@ -268,9 +268,37 @@ "type": "integer" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/discover/data.json.gz b/x-pack/test/functional/es_archives/discover/data.json.gz index 020ca814620a85..df05dfd8998f40 100644 Binary files a/x-pack/test/functional/es_archives/discover/data.json.gz and b/x-pack/test/functional/es_archives/discover/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/discover/mappings.json b/x-pack/test/functional/es_archives/discover/mappings.json index 724d6cdd018f8b..f72b7c5a91dc04 100644 --- a/x-pack/test/functional/es_archives/discover/mappings.json +++ b/x-pack/test/functional/es_archives/discover/mappings.json @@ -268,9 +268,37 @@ "type": "text" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/empty_kibana/data.json.gz b/x-pack/test/functional/es_archives/empty_kibana/data.json.gz index bffef555b9bf34..d9708ad59f56f1 100644 Binary files a/x-pack/test/functional/es_archives/empty_kibana/data.json.gz and b/x-pack/test/functional/es_archives/empty_kibana/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/empty_kibana/mappings.json b/x-pack/test/functional/es_archives/empty_kibana/mappings.json index 81888c31185da8..aeea7f7bcea4b4 100644 --- a/x-pack/test/functional/es_archives/empty_kibana/mappings.json +++ b/x-pack/test/functional/es_archives/empty_kibana/mappings.json @@ -247,9 +247,37 @@ "type": "text" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/security_page.js b/x-pack/test/functional/page_objects/security_page.js index 96e30e152689f1..2581daef24f3da 100644 --- a/x-pack/test/functional/page_objects/security_page.js +++ b/x-pack/test/functional/page_objects/security_page.js @@ -142,7 +142,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { async addIndexToRole(index) { log.debug(`Adding index ${index} to role`); - const indexInput = await retry.try(() => find.byCssSelector('[data-test-subj="indicesInput0"] > div > input')); + const indexInput = await retry.try(() => find.byCssSelector('[data-test-subj="indicesInput0"] input')); await indexInput.type(index); await indexInput.type('\n'); } @@ -150,9 +150,18 @@ export function SecurityPageProvider({ getService, getPageObjects }) { async addPrivilegeToRole(privilege) { log.debug(`Adding privilege ${privilege} to role`); const privilegeInput = - await retry.try(() => find.byCssSelector('[data-test-subj="privilegesInput0"] > div > input')); + await retry.try(() => find.byCssSelector('[data-test-subj="privilegesInput0"] input')); await privilegeInput.type(privilege); - await privilegeInput.type('\n'); + + const btn = await find.byButtonText(privilege); + await btn.click(); + + // const options = await find.byCssSelector(`.euiComboBoxOption`); + // Object.entries(options).forEach(([key, prop]) => { + // console.log({ key, proto: prop.__proto__ }); + // }); + + // await options.click(); } async assignRoleToUser(role) { @@ -188,7 +197,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const fullnameElement = await user.findByCssSelector('[data-test-subj="userRowFullName"]'); const usernameElement = await user.findByCssSelector('[data-test-subj="userRowUserName"]'); const rolesElement = await user.findByCssSelector('[data-test-subj="userRowRoles"]'); - const isReservedElementVisible = await user.findByCssSelector('td:last-child'); + const isReservedElementVisible = await user.findByCssSelector('td:last-child'); return { username: await usernameElement.getVisibleText(), @@ -203,9 +212,9 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const users = await testSubjects.findAll('roleRow'); return mapAsync(users, async role => { const rolenameElement = await role.findByCssSelector('[data-test-subj="roleRowName"]'); - const isReservedElementVisible = await role.findByCssSelector('td:nth-child(3)'); + const isReservedElementVisible = await role.findByCssSelector('td:nth-child(3)'); - return { + return { rolename: await rolenameElement.getVisibleText(), reserved: (await isReservedElementVisible.getProperty('innerHTML')).includes('roleRowReserved') }; @@ -243,27 +252,25 @@ export function SecurityPageProvider({ getService, getPageObjects }) { } addRole(roleName, userObj) { + const self = this; + return this.clickNewRole() .then(function () { // We have to use non-test-subject selectors because this markup is generated by ui-select. - log.debug('userObj.indices[0].names = ' + userObj.indices[0].names); + log.debug('userObj.indices[0].names = ' + userObj.elasticsearch.indices[0].names); return testSubjects.append('roleFormNameInput', roleName); }) .then(function () { return remote.setFindTimeout(defaultFindTimeout) - // We have to use non-test-subject selectors because this markup is generated by ui-select. - .findByCssSelector('[data-test-subj="indicesInput0"] .ui-select-search') - .type(userObj.indices[0].names); + .findByCssSelector('[data-test-subj="indicesInput0"] input') + .type(userObj.elasticsearch.indices[0].names + '\n'); }) .then(function () { - return remote.setFindTimeout(defaultFindTimeout) - // We have to use non-test-subject selectors because this markup is generated by ui-select. - .findByCssSelector('span.ui-select-choices-row-inner > div[ng-bind-html="indexPattern"]') - .click(); + return testSubjects.click('restrictDocumentsQuery0'); }) .then(function () { - if (userObj.indices[0].query) { - return testSubjects.setValue('queryInput0', userObj.indices[0].query); + if (userObj.elasticsearch.indices[0].query) { + return testSubjects.setValue('queryInput0', userObj.elasticsearch.indices[0].query); } }) @@ -276,19 +283,17 @@ export function SecurityPageProvider({ getService, getPageObjects }) { // We have to use non-test-subject selectors because this markup is generated by ui-select. return promise - .then(function () { + .then(async function () { log.debug('priv item = ' + privName); - remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector(`[data-test-subj="kibanaPrivileges-${privName}"]`) - .click(); + return find.byCssSelector(`[data-test-subj="kibanaMinimumPrivilege"] option[value="${privName}"]`); }) - .then(function () { - return PageObjects.common.sleep(500); + .then(function (element) { + return element.click(); }); }, Promise.resolve()); } - return userObj.kibana ? addKibanaPriv(userObj.kibana) : Promise.resolve(); + return userObj.kibana.global ? addKibanaPriv(userObj.kibana.global) : Promise.resolve(); }) .then(function () { @@ -297,25 +302,10 @@ export function SecurityPageProvider({ getService, getPageObjects }) { return priv.reduce(function (promise, privName) { // We have to use non-test-subject selectors because this markup is generated by ui-select. - return promise - .then(function () { - return remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector('[data-test-subj="privilegesInput0"] .ui-select-search') - .click(); - }) - .then(function () { - log.debug('priv item = ' + privName); - remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector(`[data-test-subj="privilegeOption-${privName}"]`) - .click(); - }) - .then(function () { - return PageObjects.common.sleep(500); - }); - + return promise.then(() => self.addPrivilegeToRole(privName)).then(() => PageObjects.common.sleep(250)); }, Promise.resolve()); } - return addPriv(userObj.indices[0].privileges); + return addPriv(userObj.elasticsearch.indices[0].privileges); }) //clicking the Granted fields and removing the asterix .then(function () { @@ -325,8 +315,8 @@ export function SecurityPageProvider({ getService, getPageObjects }) { return promise .then(function () { return remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector('[data-test-subj="fieldInput0"] .ui-select-search') - .type(fieldName + '\t'); + .findByCssSelector('[data-test-subj="fieldInput0"] input') + .type(fieldName + '\n'); }) .then(function () { return PageObjects.common.sleep(1000); @@ -335,13 +325,13 @@ export function SecurityPageProvider({ getService, getPageObjects }) { }, Promise.resolve()); } - if (userObj.indices[0].field_security) { + if (userObj.elasticsearch.indices[0].field_security) { // have to remove the '*' return remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector('div[data-test-subj="fieldInput0"] > div > span > span > span > span.ui-select-match-close') + .findByCssSelector('div[data-test-subj="fieldInput0"] .euiBadge[title="*"]') .click() .then(function () { - return addGrantedField(userObj.indices[0].field_security.grant); + return addGrantedField(userObj.elasticsearch.indices[0].field_security.grant); }); } }) //clicking save button @@ -377,10 +367,10 @@ export function SecurityPageProvider({ getService, getPageObjects }) { .then(() => { return PageObjects.common.sleep(2000); }) - .then (() => { + .then(() => { return testSubjects.getVisibleText('confirmModalBodyText'); }) - .then ((alert) => { + .then((alert) => { alertText = alert; log.debug('Delete user alert text = ' + alertText); return testSubjects.click('confirmModalConfirmButton');