[Roles] Added optional role description (#183145)

## Summary

1. Added optional role description field for Save/Edit Role page.
2. Added tooltip with description for roles ComboBox that we render on
the User and Role Mappings pages.
<details>
<summary>3. Updated <b>RolesGridPage </b>table responsive
setup.</summary>
  <br>
<table>
  <tr>
    <td>[Before] responsiveBreakpoint={false}</td>
     <td>[After] responsiveBreakpoint={true}</td>
  </tr>
  <tr>
<td><img alt="Before"
src="d8290299-e8c8-4c00-abee-ad1fe909df1d"></td>
<td><img alt="After"
src="917e2c78-6291-43f0-ab7e-7583c51fd69d"></td>
  </tr>
 </table>

</details>



7035c05b-85c6-4da0-97d3-85f6d2dbc313


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed.
[Report](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5960)
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

__Fixes: https://github.com/elastic/kibana/issues/173570__

## Release note
Added optional role description field for Save/Edit Role page.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
elena-shostak 2024-05-16 17:14:09 +02:00 committed by GitHub
parent e53ff4411b
commit 4fade37313
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 294 additions and 41 deletions

View file

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

View file

@ -31,6 +31,7 @@ The API returns the following:
--------------------------------------------------
{
"name": "my_restricted_kibana_role",
"description": "My restricted kibana role description",
"metadata" : {
"version" : 1
},

View file

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

View file

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

View file

@ -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 (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}>
<EuiFlexItem>{option.label}</EuiFlexItem>
<EuiFlexItem>
{option.value?.description ? (
<EuiToolTip position="left" content={option.value?.description}>
<EuiText size="s">{option.label}</EuiText>
</EuiToolTip>
) : (
<EuiText size="s">{option.label}</EuiText>
)}
</EuiFlexItem>
{option.value?.isDeprecated ? (
<EuiFlexItem grow={false}>
<EuiBadge color={option.color}>

View file

@ -541,6 +541,69 @@ describe('<EditRolePage />', () => {
expectSaveFormButtons(wrapper);
});
it('can render a user defined role with description', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
role: {
description: 'my custom role description',
name: 'my custom role',
metadata: {},
elasticsearch: { cluster: ['all'], indices: [], run_as: ['*'] },
kibana: [],
},
})}
/>
</KibanaContextProvider>
);
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(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
role: {
description: 'my reserved role description',
name: 'my custom role',
metadata: {
_reserved: true,
},
elasticsearch: { cluster: ['all'], indices: [], run_as: ['*'] },
kibana: [],
},
})}
/>
</KibanaContextProvider>
);
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(
<KibanaContextProvider services={coreStart}>

View file

@ -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<Props> = ({
return null;
};
const getRoleName = () => {
const getRoleNameAndDescription = () => {
return (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiFormRow
data-test-subj={'roleNameFormRow'}
label={
<FormattedMessage
id="xpack.security.management.editRole.roleNameFormRowTitle"
defaultMessage="Role name"
/>
}
helpText={
!isEditingExistingRole ? (
<FormattedMessage
id="xpack.security.management.createRole.roleNameFormRowHelpText"
defaultMessage="Once the role is created you can no longer edit its name."
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
data-test-subj={'roleNameFormRow'}
label={
<FormattedMessage
id="xpack.security.management.editRole.roleNameFormRowTitle"
defaultMessage="Role name"
/>
}
helpText={
!isEditingExistingRole ? (
<FormattedMessage
id="xpack.security.management.createRole.roleNameFormRowHelpText"
defaultMessage="Once the role is created you can no longer edit its name."
/>
) : !isRoleReserved ? (
<FormattedMessage
id="xpack.security.management.editRole.roleNameFormRowHelpText"
defaultMessage="A role's name cannot be changed once it has been created."
/>
) : undefined
}
{...validator.validateRoleName(role)}
{...(creatingRoleAlreadyExists
? { error: 'A role with this name already exists.', isInvalid: true }
: {})}
>
<EuiFieldText
name={'name'}
value={role.name || ''}
onChange={onNameChange}
onBlur={onNameBlur}
data-test-subj={'roleFormNameInput'}
disabled={isRoleReserved || isEditingExistingRole || isRoleReadOnly}
isInvalid={creatingRoleAlreadyExists}
/>
) : !isRoleReserved ? (
<FormattedMessage
id="xpack.security.management.editRole.roleNameFormRowHelpText"
defaultMessage="A role's name cannot be changed once it has been created."
/>
) : undefined
}
{...validator.validateRoleName(role)}
{...(creatingRoleAlreadyExists
? { error: 'A role with this name already exists.', isInvalid: true }
: {})}
>
<EuiFieldText
name={'name'}
value={role.name || ''}
onChange={onNameChange}
onBlur={onNameBlur}
data-test-subj={'roleFormNameInput'}
disabled={isRoleReserved || isEditingExistingRole || isRoleReadOnly}
isInvalid={creatingRoleAlreadyExists}
/>
</EuiFormRow>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
data-test-subj="roleDescriptionFormRow"
label={
<FormattedMessage
id="xpack.security.management.editRole.roleDescriptionFormRowTitle"
defaultMessage="Role description"
/>
}
>
{isRoleReserved || isRoleReadOnly ? (
<EuiToolTip
content={role.description}
display="block"
data-test-subj="roleFormDescriptionTooltip"
>
<EuiFieldText
name="description"
value={role.description ?? ''}
data-test-subj="roleFormDescriptionInput"
disabled
/>
</EuiToolTip>
) : (
<EuiFieldText
name="description"
value={role.description ?? ''}
onChange={onDescriptionChange}
data-test-subj="roleFormDescriptionInput"
/>
)}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
@ -510,6 +549,12 @@ export const EditRolePage: FunctionComponent<Props> = ({
}
};
const onDescriptionChange = (e: ChangeEvent<HTMLInputElement>) =>
setRole({
...role,
description: e.target.value.trim().length ? e.target.value : undefined,
});
const getElasticsearchPrivileges = () => {
return (
<div>
@ -787,7 +832,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
</Fragment>
)}
<EuiSpacer />
{getRoleName()}
{getRoleNameAndDescription()}
{getElasticsearchPrivileges()}
{getKibanaPrivileges()}
<EuiSpacer />

View file

@ -85,7 +85,6 @@ export class RoleValidator {
}
return valid();
}
public validateRemoteClusterPrivileges(role: Role): RoleValidationResult {
if (!this.shouldValidate) {
return valid();

View file

@ -56,6 +56,12 @@ describe('<RolesGridPage />', () => {
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('<RolesGridPage />', () => {
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('<RolesGridPage />', () => {
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('<RolesGridPage />', () => {
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: {} }],
},
]);
});

View file

@ -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<Props, State> {
<EuiInMemoryTable
itemId="name"
responsiveBreakpoint={false}
columns={this.getColumnConfig()}
selection={
this.props.readOnly
@ -254,6 +254,27 @@ export class RolesGridPage extends Component<Props, State> {
);
},
},
{
field: 'description',
name: i18n.translate('xpack.security.management.roles.descriptionColumnName', {
defaultMessage: 'Role Description',
}),
sortable: true,
truncateText: { lines: 3 },
render: (description: string, record: Role) => {
return (
<EuiToolTip position="top" content={description} display="block">
<EuiText
color="subdued"
size="s"
data-test-subj={`roleRowDescription-${record.name}`}
>
{description}
</EuiText>
</EuiToolTip>
);
},
},
];
if (this.props.buildFlavor !== 'serverless') {
config.push({

View file

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

View file

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