diff --git a/docs/api/role-management/get-all.asciidoc b/docs/api/role-management/get-all.asciidoc index 888bf0c8a137c..56c8b2c78859b 100644 --- a/docs/api/role-management/get-all.asciidoc +++ b/docs/api/role-management/get-all.asciidoc @@ -32,6 +32,7 @@ The API returns the following: [ { "name": "my_kibana_role", + "description": "My kibana role description", "metadata" : { "version" : 1 }, @@ -55,6 +56,7 @@ The API returns the following: }, { "name": "my_admin_role", + "description": "My admin role description", "metadata" : { "version" : 1 }, diff --git a/docs/api/role-management/get.asciidoc b/docs/api/role-management/get.asciidoc index b18b2e231774a..95f944a56e150 100644 --- a/docs/api/role-management/get.asciidoc +++ b/docs/api/role-management/get.asciidoc @@ -31,6 +31,7 @@ The API returns the following: -------------------------------------------------- { "name": "my_restricted_kibana_role", + "description": "My restricted kibana role description", "metadata" : { "version" : 1 }, diff --git a/docs/api/role-management/put.asciidoc b/docs/api/role-management/put.asciidoc index ce293f75b63ae..d68f3928a4063 100644 --- a/docs/api/role-management/put.asciidoc +++ b/docs/api/role-management/put.asciidoc @@ -21,6 +21,9 @@ To use the create or update role API, you must have the `manage_security` cluste [[role-management-api-response-body]] ==== Request body +`description`:: + (Optional, string) Description for the role. + `metadata`:: (Optional, object) In the `metadata` object, keys that begin with `_` are reserved for system usage. @@ -74,6 +77,7 @@ Grant access to various features in all spaces: -------------------------------------------------- $ curl -X PUT api/security/role/my_kibana_role { + "description": "my_kibana_role_description", "metadata": { "version": 1 }, @@ -112,6 +116,7 @@ Grant dashboard-only access to only the Marketing space: -------------------------------------------------- $ curl -X PUT api/security/role/my_kibana_role { + "description": "Grants dashboard-only access to only the Marketing space.", "metadata": { "version": 1 }, @@ -138,6 +143,7 @@ Grant full access to all features in the Default space: -------------------------------------------------- $ curl -X PUT api/security/role/my_kibana_role { + "description": "Grants full access to all features in the Default space.", "metadata": { "version": 1 }, @@ -162,6 +168,7 @@ Grant different access to different spaces: -------------------------------------------------- $ curl -X PUT api/security/role/my_kibana_role { + "description": "Grants full access to discover and dashboard features in the default space. Grants read access in the marketing, and sales spaces.", "metadata": { "version": 1 }, @@ -193,6 +200,7 @@ Grant access to {kib} and {es}: -------------------------------------------------- $ curl -X PUT api/security/role/my_kibana_role { + "description": "Grants all cluster privileges and full access to index1 and index2. Grants full access to remote_index1 and remote_index2, and the monitor_enrich cluster privilege on remote_cluster1. Grants all Kibana privileges in the default space.", "metadata": { "version": 1 }, diff --git a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.test.tsx b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.test.tsx index 4003ce6233bda..f841fbca7d55a 100644 --- a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.test.tsx +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.test.tsx @@ -42,6 +42,7 @@ describe('RoleComboBox', () => { }, { name: 'deprecated_role', + description: 'Deprecated role description', elasticsearch: { cluster: [], indices: [], run_as: [] }, kibana: [], metadata: { _reserved: true, _deprecated: true }, @@ -72,6 +73,7 @@ describe('RoleComboBox', () => { "label": "custom_role", "value": Object { "deprecatedReason": undefined, + "description": undefined, "isAdmin": false, "isDeprecated": false, "isReserved": false, @@ -89,6 +91,7 @@ describe('RoleComboBox', () => { "label": "reserved_role", "value": Object { "deprecatedReason": undefined, + "description": undefined, "isAdmin": false, "isDeprecated": false, "isReserved": true, @@ -106,6 +109,7 @@ describe('RoleComboBox', () => { "label": "some_admin", "value": Object { "deprecatedReason": undefined, + "description": undefined, "isAdmin": true, "isDeprecated": false, "isReserved": true, @@ -123,6 +127,7 @@ describe('RoleComboBox', () => { "label": "some_system", "value": Object { "deprecatedReason": undefined, + "description": undefined, "isAdmin": false, "isDeprecated": false, "isReserved": true, @@ -140,6 +145,7 @@ describe('RoleComboBox', () => { "label": "deprecated_role", "value": Object { "deprecatedReason": undefined, + "description": "Deprecated role description", "isAdmin": false, "isDeprecated": true, "isReserved": true, diff --git a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.tsx b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.tsx index 5e329b32c353d..c1ce607d82acf 100644 --- a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.tsx +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.tsx @@ -6,7 +6,14 @@ */ import type { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui'; -import { EuiBadge, EuiComboBox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiBadge, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiToolTip, +} from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; @@ -34,6 +41,7 @@ type Option = EuiComboBoxOptionOption<{ isSystem: boolean; isAdmin: boolean; deprecatedReason?: string; + description?: string; }>; export const RoleComboBox = (props: Props) => { @@ -57,6 +65,7 @@ export const RoleComboBox = (props: Props) => { isSystem, isAdmin, deprecatedReason: roleDefinition?.metadata?._deprecated_reason, + description: roleDefinition?.description, }, }; }; @@ -134,7 +143,15 @@ export const RoleComboBox = (props: Props) => { function renderOption(option: Option) { return ( - {option.label} + + {option.value?.description ? ( + + {option.label} + + ) : ( + {option.label} + )} + {option.value?.isDeprecated ? ( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 90246ccd6afe9..93d8fec9e15a0 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -541,6 +541,69 @@ describe('', () => { expectSaveFormButtons(wrapper); }); + it('can render a user defined role with description', async () => { + const wrapper = mountWithIntl( + + + + ); + + await waitForRender(wrapper); + + expect(wrapper.find('input[data-test-subj="roleFormDescriptionInput"]').prop('value')).toBe( + 'my custom role description' + ); + expect( + wrapper.find('input[data-test-subj="roleFormDescriptionInput"]').prop('disabled') + ).toBe(undefined); + expectSaveFormButtons(wrapper); + }); + + it('can render a reserved role with description', async () => { + const wrapper = mountWithIntl( + + + + ); + + await waitForRender(wrapper); + + expect(wrapper.find('[data-test-subj="roleFormDescriptionTooltip"]')).toHaveLength(1); + + expect(wrapper.find('input[data-test-subj="roleFormDescriptionInput"]').prop('value')).toBe( + 'my reserved role description' + ); + expect( + wrapper.find('input[data-test-subj="roleFormDescriptionInput"]').prop('disabled') + ).toBe(true); + }); + it('can render when creating a new role', async () => { const wrapper = mountWithIntl( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index 56fb561443e82..697b85feb9edb 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -18,6 +18,7 @@ import { EuiSpacer, EuiText, EuiTitle, + EuiToolTip, } from '@elastic/eui'; import type { ChangeEvent, FocusEvent, FunctionComponent, HTMLProps } from 'react'; import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'; @@ -211,6 +212,7 @@ function useRole( ? rolesAPIClient.getRole(roleName) : Promise.resolve({ name: '', + description: '', elasticsearch: { cluster: [], indices: [], run_as: [], remote_cluster: [] }, kibana: [], _unrecognized_applications: [], @@ -452,45 +454,82 @@ export const EditRolePage: FunctionComponent = ({ return null; }; - const getRoleName = () => { + const getRoleNameAndDescription = () => { return ( - - } - helpText={ - !isEditingExistingRole ? ( - - ) : !isRoleReserved ? ( - + + + } + helpText={ + !isEditingExistingRole ? ( + + ) : !isRoleReserved ? ( + + ) : undefined + } + {...validator.validateRoleName(role)} + {...(creatingRoleAlreadyExists + ? { error: 'A role with this name already exists.', isInvalid: true } + : {})} + > + - ) : undefined - } - {...validator.validateRoleName(role)} - {...(creatingRoleAlreadyExists - ? { error: 'A role with this name already exists.', isInvalid: true } - : {})} - > - - + + + + + } + > + {isRoleReserved || isRoleReadOnly ? ( + + + + ) : ( + + )} + + + ); }; @@ -510,6 +549,12 @@ export const EditRolePage: FunctionComponent = ({ } }; + const onDescriptionChange = (e: ChangeEvent) => + setRole({ + ...role, + description: e.target.value.trim().length ? e.target.value : undefined, + }); + const getElasticsearchPrivileges = () => { return (
@@ -787,7 +832,7 @@ export const EditRolePage: FunctionComponent = ({ )} - {getRoleName()} + {getRoleNameAndDescription()} {getElasticsearchPrivileges()} {getKibanaPrivileges()} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/validate_role.ts b/x-pack/plugins/security/public/management/roles/edit_role/validate_role.ts index bf85f80df1fc1..a425578ed98e5 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/validate_role.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/validate_role.ts @@ -85,7 +85,6 @@ export class RoleValidator { } return valid(); } - public validateRemoteClusterPrivileges(role: Role): RoleValidationResult { if (!this.shouldValidate) { return valid(); diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index ed6f28ea8321f..6b666cfd378f4 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -56,6 +56,12 @@ describe('', () => { elasticsearch: { cluster: [], indices: [], run_as: [] }, kibana: [{ base: [], spaces: [], feature: {} }], }, + { + name: 'test-role-with-description', + description: 'role-description', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + }, { name: 'reserved-role', elasticsearch: { cluster: [], indices: [], run_as: [] }, @@ -162,6 +168,10 @@ describe('', () => { expect(wrapper.find('a[data-test-subj="edit-role-action-disabled-role"]')).toHaveLength(1); expect(wrapper.find('a[data-test-subj="clone-role-action-disabled-role"]')).toHaveLength(1); + + expect(findTestSubject(wrapper, 'roleRowDescription-test-role-with-description')).toHaveLength( + 1 + ); }); it('hides reserved roles when instructed to', async () => { @@ -201,6 +211,12 @@ describe('', () => { elasticsearch: { cluster: [], indices: [], run_as: [] }, kibana: [{ base: [], spaces: [], feature: {} }], }, + { + name: 'test-role-with-description', + description: 'role-description', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + }, ]); findTestSubject(wrapper, 'showReservedRolesSwitch').simulate('click'); @@ -222,6 +238,12 @@ describe('', () => { elasticsearch: { cluster: [], indices: [], run_as: [] }, kibana: [{ base: [], spaces: [], feature: {} }], }, + { + name: 'test-role-with-description', + description: 'role-description', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + }, ]); }); diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.tsx index a21d0a1e99912..bb87cc61b0f84 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.tsx @@ -17,6 +17,7 @@ import { EuiSpacer, EuiSwitch, EuiText, + EuiToolTip, } from '@elastic/eui'; import _ from 'lodash'; import React, { Component } from 'react'; @@ -183,7 +184,6 @@ export class RolesGridPage extends Component { { ); }, }, + { + field: 'description', + name: i18n.translate('xpack.security.management.roles.descriptionColumnName', { + defaultMessage: 'Role Description', + }), + sortable: true, + truncateText: { lines: 3 }, + render: (description: string, record: Role) => { + return ( + + + {description} + + + ); + }, + }, ]; if (this.props.buildFlavor !== 'serverless') { config.push({ diff --git a/x-pack/test/functional/apps/security/index.ts b/x-pack/test/functional/apps/security/index.ts index 009c270d3c2a3..a65db6d3d3cf8 100644 --- a/x-pack/test/functional/apps/security/index.ts +++ b/x-pack/test/functional/apps/security/index.ts @@ -18,5 +18,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./user_email')); loadTestFile(require.resolve('./role_mappings')); loadTestFile(require.resolve('./remote_cluster_security_roles')); + loadTestFile(require.resolve('./role_description')); }); } diff --git a/x-pack/test/functional/apps/security/role_description.ts b/x-pack/test/functional/apps/security/role_description.ts new file mode 100644 index 0000000000000..eb272dec3d0a5 --- /dev/null +++ b/x-pack/test/functional/apps/security/role_description.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const security = getService('security'); + const PageObjects = getPageObjects(['security', 'settings', 'common', 'header']); + + describe('Role Description', function () { + before(async () => { + await security.testUser.setRoles(['cluster_security_manager']); + await PageObjects.security.initTests(); + await PageObjects.settings.navigateTo(); + await PageObjects.security.clickElasticsearchRoles(); + }); + + after(async () => { + // NOTE: Logout needs to happen before anything else to avoid flaky behavior + await PageObjects.security.forceLogout(); + await security.role.delete('a-role-with-description'); + await security.role.delete('a-role-without-description'); + await security.testUser.restoreDefaults(); + }); + + it('Can create role with description', async () => { + await PageObjects.security.clickCreateNewRole(); + await testSubjects.setValue('roleFormNameInput', 'a-role-with-description'); + await testSubjects.setValue('roleFormDescriptionInput', 'role description'); + await PageObjects.security.clickSaveEditRole(); + + const columnDescription = await testSubjects.getVisibleText( + 'roleRowDescription-a-role-with-description' + ); + expect(columnDescription).to.equal('role description'); + + await PageObjects.settings.clickLinkText('a-role-with-description'); + const name = await testSubjects.getAttribute('roleFormNameInput', 'value'); + const description = await testSubjects.getAttribute('roleFormDescriptionInput', 'value'); + + expect(name).to.equal('a-role-with-description'); + expect(description).to.equal('role description'); + + await PageObjects.security.clickCancelEditRole(); + }); + + it('Can create role without description', async () => { + await PageObjects.security.clickCreateNewRole(); + await testSubjects.setValue('roleFormNameInput', 'a-role-without-description'); + await PageObjects.security.clickSaveEditRole(); + + await PageObjects.settings.clickLinkText('a-role-without-description'); + const name = await testSubjects.getAttribute('roleFormNameInput', 'value'); + const description = await testSubjects.getAttribute('roleFormDescriptionInput', 'value'); + + expect(name).to.equal('a-role-without-description'); + expect(description).to.equal(''); + + await PageObjects.security.clickCancelEditRole(); + }); + }); +}