diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index c698e2db86ddb..f62a4d28dfc0d 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -116,7 +116,8 @@ cluster alert notifications from Monitoring. ==== Dashboard [horizontal] -`xpackDashboardMode:roles`:: The roles that belong to <>. +`xpackDashboardMode:roles`:: **Deprecated. Use <> instead.** +The roles that belong to <>. [float] [[kibana-discover-settings]] diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 3521d7ef9c66e..9b672d40961d8 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -120,6 +120,7 @@ export class DocLinksService { }, management: { kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, + dashboardSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-dashboard-settings`, }, }, }); diff --git a/x-pack/legacy/plugins/dashboard_mode/index.js b/x-pack/legacy/plugins/dashboard_mode/index.js index 94655adf981b4..ab90c6511de01 100644 --- a/x-pack/legacy/plugins/dashboard_mode/index.js +++ b/x-pack/legacy/plugins/dashboard_mode/index.js @@ -33,6 +33,15 @@ export function dashboardMode(kibana) { ), value: ['kibana_dashboard_only_user'], category: ['dashboard'], + deprecation: { + message: i18n.translate( + 'xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDeprecation', + { + defaultMessage: 'This setting is deprecated and will be removed in Kibana 8.0.', + } + ), + docLinksKey: 'dashboardSettings', + }, }, }, app: { diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 121791d113bd5..88da416cf715b 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -15,10 +15,12 @@ export { RoleIndexPrivilege, RoleKibanaPrivilege, copyRole, - isReadOnlyRole, - isReservedRole, + isRoleDeprecated, + isRoleReadOnly, + isRoleReserved, isRoleEnabled, prepareRoleClone, + getExtendedRoleDeprecationNotice, } from './role'; export { KibanaPrivileges } from './kibana_privileges'; export { diff --git a/x-pack/plugins/security/common/model/role.test.ts b/x-pack/plugins/security/common/model/role.test.ts index d4a910a1785eb..b17e264f3cdd8 100644 --- a/x-pack/plugins/security/common/model/role.test.ts +++ b/x-pack/plugins/security/common/model/role.test.ts @@ -4,7 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Role, isReadOnlyRole, isReservedRole, isRoleEnabled, copyRole, prepareRoleClone } from '.'; +import { + Role, + isRoleEnabled, + isRoleReserved, + isRoleDeprecated, + isRoleReadOnly, + copyRole, + prepareRoleClone, + getExtendedRoleDeprecationNotice, +} from '../../common/model'; describe('role', () => { describe('isRoleEnabled', () => { @@ -32,14 +41,14 @@ describe('role', () => { }); }); - describe('isReservedRole', () => { + describe('isRoleReserved', () => { test('should return false if role is explicitly not reserved', () => { const testRole = { metadata: { _reserved: false, }, }; - expect(isReservedRole(testRole)).toBe(false); + expect(isRoleReserved(testRole)).toBe(false); }); test('should return true if role is explicitly reserved', () => { @@ -48,30 +57,74 @@ describe('role', () => { _reserved: true, }, }; - expect(isReservedRole(testRole)).toBe(true); + expect(isRoleReserved(testRole)).toBe(true); }); test('should return false if role is NOT explicitly reserved or not reserved', () => { const testRole = {}; - expect(isReservedRole(testRole)).toBe(false); + expect(isRoleReserved(testRole)).toBe(false); }); }); - describe('isReadOnlyRole', () => { + describe('isRoleDeprecated', () => { + test('should return false if role is explicitly not deprecated', () => { + const testRole = { + metadata: { + _deprecated: false, + }, + }; + expect(isRoleDeprecated(testRole)).toBe(false); + }); + + test('should return true if role is explicitly deprecated', () => { + const testRole = { + metadata: { + _deprecated: true, + }, + }; + expect(isRoleDeprecated(testRole)).toBe(true); + }); + + test('should return false if role is NOT explicitly deprecated or not deprecated', () => { + const testRole = {}; + expect(isRoleDeprecated(testRole)).toBe(false); + }); + }); + + describe('getExtendedRoleDeprecationNotice', () => { + test('advises not to use the deprecated role', () => { + const testRole = { name: 'test-role' }; + expect(getExtendedRoleDeprecationNotice(testRole)).toMatchInlineSnapshot( + `"The test-role role is deprecated. "` + ); + }); + + test('includes the deprecation reason when provided', () => { + const testRole = { + name: 'test-role', + metadata: { _deprecated_reason: "We just don't like this role anymore" }, + }; + expect(getExtendedRoleDeprecationNotice(testRole)).toMatchInlineSnapshot( + `"The test-role role is deprecated. We just don't like this role anymore"` + ); + }); + }); + + describe('isRoleReadOnly', () => { test('returns true for reserved roles', () => { const testRole = { metadata: { _reserved: true, }, }; - expect(isReadOnlyRole(testRole)).toBe(true); + expect(isRoleReadOnly(testRole)).toBe(true); }); test('returns true for roles with transform errors', () => { const testRole = { _transform_error: ['kibana'], }; - expect(isReadOnlyRole(testRole)).toBe(true); + expect(isRoleReadOnly(testRole)).toBe(true); }); test('returns false for disabled roles', () => { @@ -80,12 +133,12 @@ describe('role', () => { enabled: false, }, }; - expect(isReadOnlyRole(testRole)).toBe(false); + expect(isRoleReadOnly(testRole)).toBe(false); }); test('returns false for all other roles', () => { const testRole = {}; - expect(isReadOnlyRole(testRole)).toBe(false); + expect(isRoleReadOnly(testRole)).toBe(false); }); }); diff --git a/x-pack/plugins/security/common/model/role.ts b/x-pack/plugins/security/common/model/role.ts index 1edcf147262ed..4cc7271eaca13 100644 --- a/x-pack/plugins/security/common/model/role.ts +++ b/x-pack/plugins/security/common/model/role.ts @@ -5,6 +5,7 @@ */ import { cloneDeep } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { FeaturesPrivileges } from './features_privileges'; export interface RoleIndexPrivilege { @@ -57,17 +58,41 @@ export function isRoleEnabled(role: Partial) { * * @param role Role as returned by roles API */ -export function isReservedRole(role: Partial) { +export function isRoleReserved(role: Partial) { return (role.metadata?._reserved as boolean) ?? false; } +/** + * Returns whether given role is deprecated or not. + * + * @param {role} the Role as returned by roles API + */ +export function isRoleDeprecated(role: Partial) { + return role.metadata?._deprecated ?? false; +} + +/** + * Returns the extended deprecation notice for the provided role. + * + * @param role the Role as returned by roles API + */ +export function getExtendedRoleDeprecationNotice(role: Partial) { + return i18n.translate('xpack.security.common.extendedRoleDeprecationNotice', { + defaultMessage: `The {roleName} role is deprecated. {reason}`, + values: { + roleName: role.name, + reason: getRoleDeprecatedReason(role), + }, + }); +} + /** * Returns whether given role is editable through the UI or not. * * @param role the Role as returned by roles API */ -export function isReadOnlyRole(role: Partial): boolean { - return isReservedRole(role) || (role._transform_error?.length ?? 0) > 0; +export function isRoleReadOnly(role: Partial): boolean { + return isRoleReserved(role) || (role._transform_error?.length ?? 0) > 0; } /** @@ -91,3 +116,12 @@ export function prepareRoleClone(role: Role): Role { return clone; } + +/** + * Returns the reason this role is deprecated. + * + * @param role the Role as returned by roles API + */ +function getRoleDeprecatedReason(role: Partial) { + return role.metadata?._deprecated_reason ?? ''; +} diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index 46bbedd37c434..e58d8e8421547 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -48,7 +48,7 @@ describe('', () => { ); @@ -70,7 +70,7 @@ describe('', () => { ); @@ -88,7 +88,7 @@ describe('', () => { ); @@ -106,7 +106,7 @@ describe('', () => { ); @@ -125,7 +125,7 @@ describe('', () => { ); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 3f764adc7949f..9388c2e9b19b8 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -14,11 +14,11 @@ import { PersonalInfo } from './personal_info'; interface Props { authc: AuthenticationServiceSetup; - apiClient: PublicMethodsOf; + userAPIClient: PublicMethodsOf; notifications: NotificationsStart; } -export const AccountManagementPage = ({ apiClient, authc, notifications }: Props) => { +export const AccountManagementPage = ({ userAPIClient, authc, notifications }: Props) => { const [currentUser, setCurrentUser] = useState(null); useEffect(() => { authc.getCurrentUser().then(setCurrentUser); @@ -40,7 +40,11 @@ export const AccountManagementPage = ({ apiClient, authc, notifications }: Props - + diff --git a/x-pack/plugins/security/public/account_management/change_password/change_password.tsx b/x-pack/plugins/security/public/account_management/change_password/change_password.tsx index f5ac5f3b21d2e..5b27df24f975c 100644 --- a/x-pack/plugins/security/public/account_management/change_password/change_password.tsx +++ b/x-pack/plugins/security/public/account_management/change_password/change_password.tsx @@ -13,7 +13,7 @@ import { ChangePasswordForm } from '../../management/users/components/change_pas interface Props { user: AuthenticatedUser; - apiClient: PublicMethodsOf; + userAPIClient: PublicMethodsOf; notifications: NotificationsSetup; } @@ -48,7 +48,7 @@ export class ChangePassword extends Component { diff --git a/x-pack/plugins/security/public/management/badges/deprecated_badge.tsx b/x-pack/plugins/security/public/management/badges/deprecated_badge.tsx new file mode 100644 index 0000000000000..63c38b4f3a828 --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/deprecated_badge.tsx @@ -0,0 +1,28 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiBadge, EuiToolTipProps } from '@elastic/eui'; +import { OptionalToolTip } from './optional_tooltip'; + +interface Props { + 'data-test-subj'?: string; + tooltipContent?: EuiToolTipProps['content']; +} + +export const DeprecatedBadge = (props: Props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/security/public/management/badges/disabled_badge.tsx b/x-pack/plugins/security/public/management/badges/disabled_badge.tsx new file mode 100644 index 0000000000000..a1b851e8c28a3 --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/disabled_badge.tsx @@ -0,0 +1,25 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiBadge, EuiToolTipProps } from '@elastic/eui'; +import { OptionalToolTip } from './optional_tooltip'; + +interface Props { + 'data-test-subj'?: string; + tooltipContent?: EuiToolTipProps['content']; +} + +export const DisabledBadge = (props: Props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/security/public/management/badges/enabled_badge.tsx b/x-pack/plugins/security/public/management/badges/enabled_badge.tsx new file mode 100644 index 0000000000000..4c7d3d6dd596c --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/enabled_badge.tsx @@ -0,0 +1,25 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiBadge, EuiToolTipProps } from '@elastic/eui'; +import { OptionalToolTip } from './optional_tooltip'; + +interface Props { + 'data-test-subj'?: string; + tooltipContent?: EuiToolTipProps['content']; +} + +export const EnabledBadge = (props: Props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/security/public/management/badges/index.ts b/x-pack/plugins/security/public/management/badges/index.ts new file mode 100644 index 0000000000000..b29bac6f0928a --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { DeprecatedBadge } from './deprecated_badge'; +export { DisabledBadge } from './disabled_badge'; +export { EnabledBadge } from './enabled_badge'; +export { ReservedBadge } from './reserved_badge'; diff --git a/x-pack/plugins/security/public/management/badges/optional_tooltip.tsx b/x-pack/plugins/security/public/management/badges/optional_tooltip.tsx new file mode 100644 index 0000000000000..4c412396ac7ec --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/optional_tooltip.tsx @@ -0,0 +1,19 @@ +/* + * 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, { ReactElement } from 'react'; +import { EuiToolTipProps, EuiToolTip } from '@elastic/eui'; + +interface Props { + children: ReactElement; + tooltipContent?: EuiToolTipProps['content']; +} +export const OptionalToolTip = (props: Props) => { + if (props.tooltipContent) { + return {props.children}; + } + return props.children; +}; diff --git a/x-pack/plugins/security/public/management/badges/reserved_badge.tsx b/x-pack/plugins/security/public/management/badges/reserved_badge.tsx new file mode 100644 index 0000000000000..603e3fa372aec --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/reserved_badge.tsx @@ -0,0 +1,25 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiBadge, EuiToolTipProps } from '@elastic/eui'; +import { OptionalToolTip } from './optional_tooltip'; + +interface Props { + 'data-test-subj'?: string; + tooltipContent?: EuiToolTipProps['content']; +} + +export const ReservedBadge = (props: Props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/security/public/management/role_combo_box/index.ts b/x-pack/plugins/security/public/management/role_combo_box/index.ts new file mode 100644 index 0000000000000..b7c827a22205f --- /dev/null +++ b/x-pack/plugins/security/public/management/role_combo_box/index.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 { RoleComboBox } from './role_combo_box'; 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 new file mode 100644 index 0000000000000..6a041144d0b6a --- /dev/null +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +import { RoleComboBox } from '.'; +import { EuiComboBox } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +describe('RoleComboBox', () => { + it('renders the provided list of roles via EuiComboBox options', () => { + const availableRoles = [ + { + name: 'role-1', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [], + metadata: {}, + }, + { + name: 'role-2', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [], + metadata: {}, + }, + ]; + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "color": "default", + "data-test-subj": "roleOption-role-1", + "label": "role-1", + "value": Object { + "isDeprecated": false, + }, + }, + Object { + "color": "default", + "data-test-subj": "roleOption-role-2", + "label": "role-2", + "value": Object { + "isDeprecated": false, + }, + }, + ] + `); + }); + + it('renders deprecated roles as such', () => { + const availableRoles = [ + { + name: 'role-1', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [], + metadata: { _deprecated: true }, + }, + ]; + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "color": "warning", + "data-test-subj": "roleOption-role-1", + "label": "role-1", + "value": Object { + "isDeprecated": true, + }, + }, + ] + `); + }); + + it('renders the selected role names in the expanded list, coded according to deprecated status', () => { + const availableRoles = [ + { + name: 'role-1', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [], + metadata: {}, + }, + { + name: 'role-2', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [], + metadata: {}, + }, + ]; + const wrapper = mountWithIntl( +
+ +
+ ); + + findTestSubject(wrapper, 'comboBoxToggleListButton').simulate('click'); + + wrapper.find(EuiComboBox).setState({ isListOpen: true }); + + expect(findTestSubject(wrapper, 'rolesDropdown-renderOption')).toMatchInlineSnapshot(`null`); + }); +}); 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 new file mode 100644 index 0000000000000..65fd8a8324a7d --- /dev/null +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box.tsx @@ -0,0 +1,61 @@ +/* + * 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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox } from '@elastic/eui'; +import { Role, isRoleDeprecated } from '../../../common/model'; +import { RoleComboBoxOption } from './role_combo_box_option'; + +interface Props { + availableRoles: Role[]; + selectedRoleNames: string[]; + onChange: (selectedRoleNames: string[]) => void; + placeholder?: string; + isLoading?: boolean; + isDisabled?: boolean; +} + +export const RoleComboBox = (props: Props) => { + const onRolesChange = (selectedItems: Array<{ label: string }>) => { + props.onChange(selectedItems.map(item => item.label)); + }; + + const roleNameToOption = (roleName: string) => { + const roleDefinition = props.availableRoles.find(role => role.name === roleName); + const isDeprecated: boolean = (roleDefinition && isRoleDeprecated(roleDefinition)) ?? false; + return { + color: isDeprecated ? 'warning' : 'default', + 'data-test-subj': `roleOption-${roleName}`, + label: roleName, + value: { + isDeprecated, + }, + }; + }; + + const options = props.availableRoles.map(role => roleNameToOption(role.name)); + + const selectedOptions = props.selectedRoleNames.map(roleNameToOption); + + return ( + } + /> + ); +}; diff --git a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.test.tsx b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.test.tsx new file mode 100644 index 0000000000000..c1ac381ba9994 --- /dev/null +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.test.tsx @@ -0,0 +1,57 @@ +/* + * 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 from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { RoleComboBoxOption } from './role_combo_box_option'; + +describe('RoleComboBoxOption', () => { + it('renders a regular role correctly', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper).toMatchInlineSnapshot(` + + role-1 + + + `); + }); + + it('renders a deprecated role correctly', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper).toMatchInlineSnapshot(` + + role-1 + + (deprecated) + + `); + }); +}); diff --git a/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx new file mode 100644 index 0000000000000..126a3151adf01 --- /dev/null +++ b/x-pack/plugins/security/public/management/role_combo_box/role_combo_box_option.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiComboBoxOptionProps, EuiText } from '@elastic/eui'; + +interface Props { + option: EuiComboBoxOptionProps<{ isDeprecated: boolean }>; +} + +export const RoleComboBoxOption = ({ option }: Props) => { + const isDeprecated = option.value?.isDeprecated ?? false; + const deprecatedLabel = i18n.translate( + 'xpack.security.management.users.editUser.deprecatedRoleText', + { + defaultMessage: '(deprecated)', + } + ); + + return ( + + {option.label} {isDeprecated ? deprecatedLabel : ''} + + ); +}; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx index 96bc81c8cd4d0..149c1271123d2 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx @@ -17,7 +17,6 @@ import { EditRoleMappingPage } from '.'; import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../components'; import { VisualRuleEditor } from './rule_editor_panel/visual_rule_editor'; import { JSONRuleEditor } from './rule_editor_panel/json_rule_editor'; -import { EuiComboBox } from '@elastic/eui'; import { RolesAPIClient } from '../../roles'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; @@ -25,6 +24,7 @@ import { DocumentationLinksService } from '../documentation_links'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { roleMappingsAPIClientMock } from '../role_mappings_api_client.mock'; import { rolesAPIClientMock } from '../../roles/roles_api_client.mock'; +import { RoleComboBox } from '../../role_combo_box'; describe('EditRoleMappingPage', () => { let rolesAPI: PublicMethodsOf; @@ -33,6 +33,7 @@ describe('EditRoleMappingPage', () => { (rolesAPI as jest.Mocked).getRoles.mockResolvedValue([ { name: 'foo_role' }, { name: 'bar role' }, + { name: 'some-deprecated-role', metadata: { _deprecated: true } }, ] as Role[]); }); @@ -63,10 +64,10 @@ describe('EditRoleMappingPage', () => { target: { value: 'my-role-mapping' }, }); - (wrapper - .find(EuiComboBox) - .filter('[data-test-subj="roleMappingFormRoleComboBox"]') - .props() as any).onChange([{ label: 'foo_role' }]); + wrapper + .find(RoleComboBox) + .props() + .onChange(['foo_role']); findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); @@ -126,10 +127,10 @@ describe('EditRoleMappingPage', () => { findTestSubject(wrapper, 'switchToRolesButton').simulate('click'); - (wrapper - .find(EuiComboBox) - .filter('[data-test-subj="roleMappingFormRoleComboBox"]') - .props() as any).onChange([{ label: 'foo_role' }]); + wrapper + .find(RoleComboBox) + .props() + .onChange(['foo_role']); findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); wrapper.find('button[id="addRuleOption"]').simulate('click'); @@ -207,6 +208,42 @@ describe('EditRoleMappingPage', () => { expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1); }); + it('renders a message when editing a mapping with deprecated roles assigned', async () => { + const roleMappingsAPI = roleMappingsAPIClientMock.create(); + roleMappingsAPI.getRoleMapping.mockResolvedValue({ + name: 'foo', + roles: ['some-deprecated-role'], + enabled: true, + rules: { + field: { username: '*' }, + }, + }); + roleMappingsAPI.checkRoleMappingFeatures.mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }); + + const { docLinks, notifications } = coreMock.createStart(); + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'deprecatedRolesAssigned')).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'deprecatedRolesAssigned')).toHaveLength(1); + }); + it('renders a warning when editing a mapping with a stored role template, when stored scripts are disabled', async () => { const roleMappingsAPI = roleMappingsAPIClientMock.create(); roleMappingsAPI.getRoleMapping.mockResolvedValue({ diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/mapping_info_panel/mapping_info_panel.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/mapping_info_panel/mapping_info_panel.tsx index 02af6bfbafa7e..b376a3943ff48 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/mapping_info_panel/mapping_info_panel.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/mapping_info_panel/mapping_info_panel.tsx @@ -17,6 +17,7 @@ import { EuiIcon, EuiSwitch, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { RoleMapping } from '../../../../../common/model'; import { RolesAPIClient } from '../../../roles'; @@ -276,12 +277,12 @@ export class MappingInfoPanel extends Component { > - } + label={i18n.translate( + 'xpack.security.management.editRoleMapping.roleMappingEnabledLabel', + { + defaultMessage: 'Enable mapping', + } + )} showLabel={false} data-test-subj="roleMappingsEnabledSwitch" checked={this.props.roleMapping.enabled} diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/role_selector/role_selector.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/role_selector/role_selector.tsx index 992c2741ae93e..8e1597cf3d598 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/role_selector/role_selector.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/role_selector/role_selector.tsx @@ -6,11 +6,13 @@ import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox, EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; -import { RoleMapping, Role } from '../../../../../common/model'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; +import { RoleMapping, Role, isRoleDeprecated } from '../../../../../common/model'; import { RolesAPIClient } from '../../../roles'; import { AddRoleTemplateButton } from './add_role_template_button'; import { RoleTemplateEditor } from './role_template_editor'; +import { RoleComboBox } from '../../../role_combo_box'; interface Props { rolesAPIClient: PublicMethodsOf; @@ -40,7 +42,7 @@ export class RoleSelector extends React.Component { public render() { const { mode } = this.props; return ( - + {mode === 'roles' ? this.getRoleComboBox() : this.getRoleTemplates()} ); @@ -49,19 +51,18 @@ export class RoleSelector extends React.Component { private getRoleComboBox = () => { const { roles = [] } = this.props.roleMapping; return ( - ({ label: r.name }))} - selectedOptions={roles!.map(r => ({ label: r }))} - onChange={selectedOptions => { + availableRoles={this.state.roles} + selectedRoleNames={roles} + onChange={selectedRoles => { this.props.onChange({ ...this.props.roleMapping, - roles: selectedOptions.map(so => so.label), + roles: selectedRoles, role_templates: [], }); }} @@ -130,4 +131,25 @@ export class RoleSelector extends React.Component { ); }; + + private getHelpText = () => { + if (this.props.mode === 'roles' && this.hasDeprecatedRolesAssigned()) { + return ( + + + + ); + } + }; + + private hasDeprecatedRolesAssigned = () => { + return ( + this.props.roleMapping.roles?.some(r => + this.state.roles.some(role => role.name === r && isRoleDeprecated(role)) + ) ?? false + ); + }; } diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx index de0722b4cd85e..0d343ad33d78e 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx @@ -16,6 +16,7 @@ import { DocumentationLinksService } from '../documentation_links'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { roleMappingsAPIClientMock } from '../role_mappings_api_client.mock'; +import { rolesAPIClientMock } from '../../roles/index.mock'; describe('RoleMappingsGridPage', () => { it('renders an empty prompt when no role mappings exist', async () => { @@ -29,6 +30,7 @@ describe('RoleMappingsGridPage', () => { const { docLinks, notifications } = coreMock.createStart(); const wrapper = mountWithIntl( { const { docLinks, notifications } = coreMock.createStart(); const wrapper = mountWithIntl( { const { docLinks, notifications } = coreMock.createStart(); const wrapper = mountWithIntl( { expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1); }); - it('renders links to mapped roles', async () => { + it('renders links to mapped roles, even if the roles API call returns nothing', async () => { const roleMappingsAPI = roleMappingsAPIClientMock.create(); roleMappingsAPI.getRoleMappings.mockResolvedValue([ { @@ -122,6 +126,7 @@ describe('RoleMappingsGridPage', () => { const { docLinks, notifications } = coreMock.createStart(); const wrapper = mountWithIntl( { const { docLinks, notifications } = coreMock.createStart(); const wrapper = mountWithIntl( { const { docLinks, notifications } = coreMock.createStart(); const wrapper = mountWithIntl( { // Expect an additional API call to refresh the grid expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(2); }); + + it('renders a warning when a mapping is assigned a deprecated role', async () => { + const roleMappingsAPI = roleMappingsAPIClientMock.create(); + roleMappingsAPI.getRoleMappings.mockResolvedValue([ + { + name: 'some-realm', + enabled: true, + roles: ['superuser', 'kibana_user'], + rules: { field: { username: '*' } }, + }, + ]); + roleMappingsAPI.checkRoleMappingFeatures.mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }); + roleMappingsAPI.deleteRoleMappings.mockResolvedValue([ + { + name: 'some-realm', + success: true, + }, + ]); + + const roleAPIClient = rolesAPIClientMock.create(); + roleAPIClient.getRoles.mockResolvedValue([ + { + name: 'kibana_user', + metadata: { + _deprecated: true, + _deprecated_reason: `I don't like you.`, + }, + }, + ]); + + const { docLinks, notifications } = coreMock.createStart(); + const wrapper = mountWithIntl( + + ); + await nextTick(); + wrapper.update(); + + const deprecationTooltip = wrapper.find('[data-test-subj="roleDeprecationTooltip"]').props(); + + expect(deprecationTooltip).toMatchInlineSnapshot(` + Object { + "children":
+ kibana_user + + +
, + "content": "The kibana_user role is deprecated. I don't like you.", + "data-test-subj": "roleDeprecationTooltip", + "delay": "regular", + "position": "top", + } + `); + }); }); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx index feb918cb6b301..5802c3444e85f 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx @@ -6,7 +6,6 @@ import React, { Component, Fragment } from 'react'; import { - EuiBadge, EuiButton, EuiButtonIcon, EuiCallOut, @@ -26,7 +25,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NotificationsStart } from 'src/core/public'; -import { RoleMapping } from '../../../../common/model'; +import { RoleMapping, Role } from '../../../../common/model'; import { EmptyPrompt } from './empty_prompt'; import { NoCompatibleRealms, @@ -34,15 +33,15 @@ import { PermissionDenied, SectionLoading, } from '../components'; -import { - getCreateRoleMappingHref, - getEditRoleMappingHref, - getEditRoleHref, -} from '../../management_urls'; +import { getCreateRoleMappingHref, getEditRoleMappingHref } from '../../management_urls'; import { DocumentationLinksService } from '../documentation_links'; import { RoleMappingsAPIClient } from '../role_mappings_api_client'; +import { RoleTableDisplay } from '../../role_table_display'; +import { RolesAPIClient } from '../../roles'; +import { EnabledBadge, DisabledBadge } from '../../badges'; interface Props { + rolesAPIClient: PublicMethodsOf; roleMappingsAPI: PublicMethodsOf; notifications: NotificationsStart; docLinks: DocumentationLinksService; @@ -51,6 +50,7 @@ interface Props { interface State { loadState: 'loadingApp' | 'loadingTable' | 'permissionDenied' | 'finished'; roleMappings: null | RoleMapping[]; + roles: null | Role[]; selectedItems: RoleMapping[]; hasCompatibleRealms: boolean; error: any; @@ -62,6 +62,7 @@ export class RoleMappingsGridPage extends Component { this.state = { loadState: 'loadingApp', roleMappings: null, + roles: null, hasCompatibleRealms: true, selectedItems: [], error: undefined, @@ -308,7 +309,7 @@ export class RoleMappingsGridPage extends Component { }), sortable: true, render: (entry: any, record: RoleMapping) => { - const { roles = [], role_templates: roleTemplates = [] } = record; + const { roles: assignedRoleNames = [], role_templates: roleTemplates = [] } = record; if (roleTemplates.length > 0) { return ( @@ -322,13 +323,11 @@ export class RoleMappingsGridPage extends Component { ); } - const roleLinks = roles.map((rolename, index) => { - return ( - - {rolename} - {index === roles.length - 1 ? null : ', '} - - ); + const roleLinks = assignedRoleNames.map((rolename, index) => { + const role: Role | string = + this.state.roles?.find(r => r.name === rolename) ?? rolename; + + return ; }); return
{roleLinks}
; }, @@ -341,24 +340,10 @@ export class RoleMappingsGridPage extends Component { sortable: true, render: (enabled: boolean) => { if (enabled) { - return ( - - - - ); + return ; } - return ( - - - - ); + return ; }, }, { @@ -458,13 +443,27 @@ export class RoleMappingsGridPage extends Component { }); if (canManageRoleMappings) { - this.loadRoleMappings(); + this.performInitialLoad(); } } catch (e) { this.setState({ error: e, loadState: 'finished' }); } } + private performInitialLoad = async () => { + try { + const [roleMappings, roles] = await Promise.all([ + this.props.roleMappingsAPI.getRoleMappings(), + this.props.rolesAPIClient.getRoles(), + ]); + this.setState({ roleMappings, roles }); + } catch (e) { + this.setState({ error: e }); + } + + this.setState({ loadState: 'finished' }); + }; + private reloadRoleMappings = () => { this.setState({ roleMappings: [], loadState: 'loadingTable' }); this.loadRoleMappings(); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index 9c41d6624065e..5907413d7299e 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -53,7 +53,7 @@ describe('roleMappingsManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Role Mappings' }]); expect(container).toMatchInlineSnapshot(`
- Role Mappings Page: {"notifications":{"toasts":{}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"}} + Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"}}
`); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx index af1572cedbade..8e1ac8d7f6957 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx @@ -48,6 +48,7 @@ export const roleMappingsManagementApp = Object.freeze({ return ( diff --git a/x-pack/plugins/security/public/management/role_table_display/index.ts b/x-pack/plugins/security/public/management/role_table_display/index.ts new file mode 100644 index 0000000000000..71f100ee68bfa --- /dev/null +++ b/x-pack/plugins/security/public/management/role_table_display/index.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 { RoleTableDisplay } from './role_table_display'; diff --git a/x-pack/plugins/security/public/management/role_table_display/role_table_display.tsx b/x-pack/plugins/security/public/management/role_table_display/role_table_display.tsx new file mode 100644 index 0000000000000..28978f0090011 --- /dev/null +++ b/x-pack/plugins/security/public/management/role_table_display/role_table_display.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiLink, EuiToolTip, EuiIcon } from '@elastic/eui'; +import { Role, isRoleDeprecated, getExtendedRoleDeprecationNotice } from '../../../common/model'; +import { getEditRoleHref } from '../management_urls'; + +interface Props { + role: Role | string; +} + +export const RoleTableDisplay = ({ role }: Props) => { + let content; + let href; + if (typeof role === 'string') { + content =
{role}
; + href = getEditRoleHref(role); + } else if (isRoleDeprecated(role)) { + content = ( + +
+ {role.name} +
+
+ ); + href = getEditRoleHref(role.name); + } else { + content =
{role.name}
; + href = getEditRoleHref(role.name); + } + return {content}; +}; 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 42ec3fa419167..cd7766ef38748 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 @@ -17,6 +17,7 @@ import { EuiSpacer, EuiText, EuiTitle, + EuiCallOut, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -44,11 +45,13 @@ import { RawKibanaPrivileges, Role, BuiltinESPrivileges, - isReadOnlyRole as checkIfRoleReadOnly, - isReservedRole as checkIfRoleReserved, + isRoleReadOnly as checkIfRoleReadOnly, + isRoleReserved as checkIfRoleReserved, + isRoleDeprecated as checkIfRoleDeprecated, copyRole, prepareRoleClone, RoleIndexPrivilege, + getExtendedRoleDeprecationNotice, } from '../../../../common/model'; import { ROLES_PATH } from '../../management_urls'; import { RoleValidationResult, RoleValidator } from './validate_role'; @@ -299,8 +302,9 @@ export const EditRolePage: FunctionComponent = ({ } const isEditingExistingRole = !!roleName && action === 'edit'; - const isReadOnlyRole = checkIfRoleReadOnly(role); - const isReservedRole = checkIfRoleReserved(role); + const isRoleReadOnly = checkIfRoleReadOnly(role); + const isRoleReserved = checkIfRoleReserved(role); + const isDeprecatedRole = checkIfRoleDeprecated(role); const [kibanaPrivileges, builtInESPrivileges] = privileges; @@ -309,7 +313,7 @@ export const EditRolePage: FunctionComponent = ({ const props: HTMLProps = { tabIndex: 0, }; - if (isReservedRole) { + if (isRoleReserved) { titleText = ( = ({ }; const getActionButton = () => { - if (isEditingExistingRole && !isReadOnlyRole) { + if (isEditingExistingRole && !isRoleReadOnly) { return ( @@ -365,7 +369,7 @@ export const EditRolePage: FunctionComponent = ({ /> } helpText={ - !isReservedRole && isEditingExistingRole ? ( + !isRoleReserved && isEditingExistingRole ? ( = ({ value={role.name || ''} onChange={onNameChange} data-test-subj={'roleFormNameInput'} - readOnly={isReservedRole || isEditingExistingRole} + readOnly={isRoleReserved || isEditingExistingRole} />
@@ -400,7 +404,7 @@ export const EditRolePage: FunctionComponent = ({ = ({ spacesEnabled={spacesEnabled} features={features} uiCapabilities={uiCapabilities} - editable={!isReadOnlyRole} + editable={!isRoleReadOnly} role={role} onChange={onRoleChange} validator={validator} @@ -436,7 +440,7 @@ export const EditRolePage: FunctionComponent = ({ }; const getFormButtons = () => { - if (isReadOnlyRole) { + if (isRoleReadOnly) { return getReturnToRoleListButton(); } @@ -479,7 +483,7 @@ export const EditRolePage: FunctionComponent = ({ data-test-subj={`roleFormSaveButton`} fill onClick={saveRole} - disabled={isReservedRole} + disabled={isRoleReserved} > {saveText} @@ -563,7 +567,7 @@ export const EditRolePage: FunctionComponent = ({ {description} - {isReservedRole && ( + {isRoleReserved && ( @@ -577,6 +581,17 @@ export const EditRolePage: FunctionComponent = ({ )} + {isDeprecatedRole && ( + + + + + )} + {getRoleName()} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/cluster_privileges.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/cluster_privileges.tsx index 380d54733ce0e..54be04ade370e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/cluster_privileges.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/cluster_privileges.tsx @@ -7,7 +7,7 @@ import { EuiComboBox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { Component } from 'react'; import _ from 'lodash'; -import { Role, isReadOnlyRole } from '../../../../../../common/model'; +import { Role, isRoleReadOnly } from '../../../../../../common/model'; interface Props { role: Role; @@ -38,7 +38,7 @@ export class ClusterPrivileges extends Component { selectedOptions={selectedOptions} onChange={this.onClusterPrivilegesChange} onCreateOption={this.onCreateCustomPrivilege} - isDisabled={isReadOnlyRole(role)} + isDisabled={isRoleReadOnly(role)} /> ); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx index 5e2da51314365..879cd8e2759ab 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx @@ -23,7 +23,7 @@ test('it renders without crashing', () => { indexPatterns: [], availableFields: [], availableIndexPrivileges: ['all', 'read', 'write', 'index'], - isReadOnlyRole: false, + isRoleReadOnly: false, allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), @@ -50,7 +50,7 @@ describe('delete button', () => { indexPatterns: [], availableFields: [], availableIndexPrivileges: ['all', 'read', 'write', 'index'], - isReadOnlyRole: false, + isRoleReadOnly: false, allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), @@ -59,19 +59,19 @@ describe('delete button', () => { intl: {} as any, }; - test('it is hidden when isReadOnlyRole is true', () => { + test('it is hidden when isRoleReadOnly is true', () => { const testProps = { ...props, - isReadOnlyRole: true, + isRoleReadOnly: true, }; const wrapper = mountWithIntl(); expect(wrapper.find(EuiButtonIcon)).toHaveLength(0); }); - test('it is shown when isReadOnlyRole is false', () => { + test('it is shown when isRoleReadOnly is false', () => { const testProps = { ...props, - isReadOnlyRole: false, + isRoleReadOnly: false, }; const wrapper = mountWithIntl(); expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); @@ -80,7 +80,7 @@ describe('delete button', () => { test('it invokes onDelete when clicked', () => { const testProps = { ...props, - isReadOnlyRole: false, + isRoleReadOnly: false, }; const wrapper = mountWithIntl(); wrapper.find(EuiButtonIcon).simulate('click'); @@ -102,7 +102,7 @@ describe(`document level security`, () => { indexPatterns: [], availableFields: [], availableIndexPrivileges: ['all', 'read', 'write', 'index'], - isReadOnlyRole: false, + isRoleReadOnly: false, allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), @@ -161,7 +161,7 @@ describe('field level security', () => { indexPatterns: [], availableFields: [], availableIndexPrivileges: ['all', 'read', 'write', 'index'], - isReadOnlyRole: false, + isRoleReadOnly: false, allowDocumentLevelSecurity: true, allowFieldLevelSecurity: true, validator: new RoleValidator(), diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx index 15e0367c2b6dc..b5d0a2c91d1be 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx @@ -33,7 +33,7 @@ interface Props { availableFields: string[]; onChange: (indexPrivilege: RoleIndexPrivilege) => void; onDelete: () => void; - isReadOnlyRole: boolean; + isRoleReadOnly: boolean; allowDocumentLevelSecurity: boolean; allowFieldLevelSecurity: boolean; validator: RoleValidator; @@ -68,7 +68,7 @@ export class IndexPrivilegeForm extends Component { {this.getPrivilegeForm()} - {!this.props.isReadOnlyRole && ( + {!this.props.isRoleReadOnly && ( { selectedOptions={this.props.indexPrivilege.names.map(toOption)} onCreateOption={this.onCreateIndexPatternOption} onChange={this.onIndexPatternsChange} - isDisabled={this.props.isReadOnlyRole} + isDisabled={this.props.isRoleReadOnly} /> @@ -128,7 +128,7 @@ export class IndexPrivilegeForm extends Component { options={this.props.availableIndexPrivileges.map(toOption)} selectedOptions={this.props.indexPrivilege.privileges.map(toOption)} onChange={this.onPrivilegeChange} - isDisabled={this.props.isReadOnlyRole} + isDisabled={this.props.isRoleReadOnly} /> @@ -149,7 +149,7 @@ export class IndexPrivilegeForm extends Component { allowDocumentLevelSecurity, availableFields, indexPrivilege, - isReadOnlyRole, + isRoleReadOnly, } = this.props; if (!allowFieldLevelSecurity) { @@ -161,7 +161,7 @@ export class IndexPrivilegeForm extends Component { return ( <> - {!isReadOnlyRole && ( + {!isRoleReadOnly && ( { { fullWidth={true} className="indexPrivilegeForm__grantedFieldsRow" helpText={ - !isReadOnlyRole && grant.length === 0 ? ( + !isRoleReadOnly && grant.length === 0 ? ( { selectedOptions={grant.map(toOption)} onCreateOption={this.onCreateGrantedField} onChange={this.onGrantedFieldsChange} - isDisabled={this.props.isReadOnlyRole} + isDisabled={this.props.isRoleReadOnly} /> @@ -233,7 +233,7 @@ export class IndexPrivilegeForm extends Component { selectedOptions={except.map(toOption)} onCreateOption={this.onCreateDeniedField} onChange={this.onDeniedFieldsChange} - isDisabled={isReadOnlyRole} + isDisabled={isRoleReadOnly} /> @@ -248,7 +248,7 @@ export class IndexPrivilegeForm extends Component { }; private getGrantedDocumentsControl = () => { - const { allowDocumentLevelSecurity, indexPrivilege, isReadOnlyRole } = this.props; + const { allowDocumentLevelSecurity, indexPrivilege, isRoleReadOnly } = this.props; if (!allowDocumentLevelSecurity) { return null; @@ -256,7 +256,7 @@ export class IndexPrivilegeForm extends Component { return ( - {!this.props.isReadOnlyRole && ( + {!this.props.isRoleReadOnly && ( { { compressed={true} checked={this.state.queryExpanded} onChange={this.toggleDocumentQuery} - disabled={isReadOnlyRole} + disabled={isRoleReadOnly} /> } @@ -292,7 +292,7 @@ export class IndexPrivilegeForm extends Component { fullWidth={true} value={indexPrivilege.query} onChange={this.onQueryChange} - readOnly={this.props.isReadOnlyRole} + readOnly={this.props.isRoleReadOnly} /> diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privileges.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privileges.tsx index 2c745067fede2..1157640ca57a7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privileges.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privileges.tsx @@ -8,7 +8,7 @@ import React, { Component, Fragment } from 'react'; import { Role, RoleIndexPrivilege, - isReadOnlyRole, + isRoleReadOnly, isRoleEnabled, } from '../../../../../../common/model'; import { SecurityLicense } from '../../../../../../common/licensing'; @@ -57,7 +57,7 @@ export class IndexPrivileges extends Component { // doesn't permit FLS/DLS). allowDocumentLevelSecurity: allowRoleDocumentLevelSecurity || !isRoleEnabled(this.props.role), allowFieldLevelSecurity: allowRoleFieldLevelSecurity || !isRoleEnabled(this.props.role), - isReadOnlyRole: isReadOnlyRole(this.props.role), + isRoleReadOnly: isRoleReadOnly(this.props.role), }; const forms = indices.map((indexPrivilege: RoleIndexPrivilege, idx) => ( @@ -143,7 +143,7 @@ export class IndexPrivileges extends Component { public loadAvailableFields(privileges: RoleIndexPrivilege[]) { // readonly roles cannot be edited, and therefore do not need to fetch available fields. - if (isReadOnlyRole(this.props.role)) { + if (isRoleReadOnly(this.props.role)) { return; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index 5fc238eed0ae7..a847ccb677485 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -17,7 +17,7 @@ import React, { Component, Fragment } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../../spaces/public'; import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, isReservedRole } from '../../../../../../../common/model'; +import { KibanaPrivileges, Role, isRoleReserved } from '../../../../../../../common/model'; import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; import { RoleValidator } from '../../../validate_role'; import { PrivilegeMatrix } from './privilege_matrix'; @@ -219,7 +219,7 @@ class SpaceAwarePrivilegeSectionUI extends Component { return ( {addPrivilegeButton} - {hasPrivilegesAssigned && !isReservedRole(this.props.role) && ( + {hasPrivilegesAssigned && !isRoleReserved(this.props.role) && ( {viewMatrixButton} )} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/reserved_role_badge.tsx b/x-pack/plugins/security/public/management/roles/edit_role/reserved_role_badge.tsx index 501ca7589dafd..3a79c400b8d59 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/reserved_role_badge.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/reserved_role_badge.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Role, isReservedRole } from '../../../../common/model'; +import { Role, isRoleReserved } from '../../../../common/model'; interface Props { role: Role; @@ -17,7 +17,7 @@ interface Props { export const ReservedRoleBadge = (props: Props) => { const { role } = props; - if (isReservedRole(role)) { + if (isRoleReserved(role)) { return ( ({ body: { statusCode: 403 } }); @@ -76,8 +78,24 @@ describe('', () => { }); expect(wrapper.find(PermissionDenied)).toHaveLength(0); - expect(wrapper.find('EuiIcon[data-test-subj="reservedRole"]')).toHaveLength(1); - expect(wrapper.find('EuiCheckbox[title="Role is reserved"]')).toHaveLength(1); + expect(wrapper.find(ReservedBadge)).toHaveLength(1); + }); + + it(`renders disabled roles as such`, async () => { + const wrapper = mountWithIntl( + + ); + const initialIconCount = wrapper.find(EuiIcon).length; + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(EuiIcon).length > initialIconCount; + }); + + expect(wrapper.find(PermissionDenied)).toHaveLength(0); + expect(wrapper.find(DisabledBadge)).toHaveLength(1); }); it('renders permission denied if required', async () => { @@ -123,4 +141,54 @@ describe('', () => { wrapper.find('EuiButtonIcon[data-test-subj="clone-role-action-disabled-role"]') ).toHaveLength(1); }); + + it('hides reserved roles when instructed to', async () => { + const wrapper = mountWithIntl( + + ); + const initialIconCount = wrapper.find(EuiIcon).length; + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(EuiIcon).length > initialIconCount; + }); + + expect(wrapper.find(EuiBasicTable).props().items).toEqual([ + { + name: 'disabled-role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + transient_metadata: { enabled: false }, + }, + { + name: 'reserved-role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + metadata: { _reserved: true }, + }, + { + name: 'test-role-1', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + }, + ]); + + findTestSubject(wrapper, 'showReservedRolesSwitch').simulate('click'); + + expect(wrapper.find(EuiBasicTable).props().items).toEqual([ + { + name: 'disabled-role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], spaces: [], feature: {} }], + transient_metadata: { enabled: false }, + }, + { + name: 'test-role-1', + 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 7c686bef391a7..04a74a1a9b99a 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 @@ -8,7 +8,6 @@ import _ from 'lodash'; import React, { Component } from 'react'; import { EuiButton, - EuiIcon, EuiInMemoryTable, EuiLink, EuiPageContent, @@ -19,14 +18,26 @@ import { EuiTitle, EuiButtonIcon, EuiBasicTableColumn, + EuiSwitchEvent, + EuiSwitch, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NotificationsStart } from 'src/core/public'; -import { Role, isRoleEnabled, isReadOnlyRole, isReservedRole } from '../../../../common/model'; +import { + Role, + isRoleEnabled, + isRoleReadOnly, + isRoleReserved, + isRoleDeprecated, + getExtendedRoleDeprecationNotice, +} from '../../../../common/model'; import { RolesAPIClient } from '../roles_api_client'; import { ConfirmDelete } from './confirm_delete'; import { PermissionDenied } from './permission_denied'; +import { DisabledBadge, DeprecatedBadge, ReservedBadge } from '../../badges'; interface Props { notifications: NotificationsStart; @@ -35,10 +46,12 @@ interface Props { interface State { roles: Role[]; + visibleRoles: Role[]; selection: Role[]; filter: string; showDeleteConfirmation: boolean; permissionDenied: boolean; + includeReservedRoles: boolean; } const getRoleManagementHref = (action: 'edit' | 'clone', roleName?: string) => { @@ -50,10 +63,12 @@ export class RolesGridPage extends Component { super(props); this.state = { roles: [], + visibleRoles: [], selection: [], filter: '', showDeleteConfirmation: false, permissionDenied: false, + includeReservedRoles: true, }; } @@ -125,16 +140,22 @@ export class RolesGridPage extends Component { initialPageSize: 20, pageSizeOptions: [10, 20, 30, 50, 100], }} - items={this.getVisibleRoles()} + items={this.state.visibleRoles} loading={roles.length === 0} search={{ toolsLeft: this.renderToolsLeft(), + toolsRight: this.renderToolsRight(), box: { incremental: true, }, onChange: (query: Record) => { this.setState({ filter: query.queryText, + visibleRoles: this.getVisibleRoles( + this.state.roles, + query.queryText, + this.state.includeReservedRoles + ), }); }, }} @@ -158,11 +179,6 @@ export class RolesGridPage extends Component { }; private getColumnConfig = () => { - const reservedRoleDesc = i18n.translate( - 'xpack.security.management.roles.reservedColumnDescription', - { defaultMessage: 'Reserved roles are built-in and cannot be edited or removed.' } - ); - return [ { field: 'name', @@ -177,35 +193,18 @@ export class RolesGridPage extends Component { {name} - {!isRoleEnabled(record) && ( - - )} ); }, }, { field: 'metadata', - name: i18n.translate('xpack.security.management.roles.reservedColumnName', { - defaultMessage: 'Reserved', + name: i18n.translate('xpack.security.management.roles.statusColumnName', { + defaultMessage: 'Status', }), - sortable: ({ metadata }: Role) => Boolean(metadata && metadata._reserved), - dataType: 'boolean', - align: 'right', - description: reservedRoleDesc, - render: (metadata: Role['metadata']) => { - const label = i18n.translate('xpack.security.management.roles.reservedRoleIconLabel', { - defaultMessage: 'Reserved role', - }); - - return metadata && metadata._reserved ? ( - - - - ) : null; + sortable: (role: Role) => isRoleEnabled(role) && !isRoleDeprecated(role), + render: (metadata: Role['metadata'], record: Role) => { + return this.getRoleStatusBadges(record); }, }, { @@ -215,7 +214,7 @@ export class RolesGridPage extends Component { width: '150px', actions: [ { - available: (role: Role) => !isReadOnlyRole(role), + available: (role: Role) => !isRoleReadOnly(role), render: (role: Role) => { const title = i18n.translate('xpack.security.management.roles.editRoleActionName', { defaultMessage: `Edit {roleName}`, @@ -235,7 +234,7 @@ export class RolesGridPage extends Component { }, }, { - available: (role: Role) => !isReservedRole(role), + available: (role: Role) => !isRoleReserved(role), render: (role: Role) => { const title = i18n.translate('xpack.security.management.roles.cloneRoleActionName', { defaultMessage: `Clone {roleName}`, @@ -259,16 +258,64 @@ export class RolesGridPage extends Component { ] as Array>; }; - private getVisibleRoles = () => { - const { roles, filter } = this.state; + private getVisibleRoles = (roles: Role[], filter: string, includeReservedRoles: boolean) => { + return roles.filter(role => { + const normalized = `${role.name}`.toLowerCase(); + const normalizedQuery = filter.toLowerCase(); + return ( + normalized.indexOf(normalizedQuery) !== -1 && + (includeReservedRoles || !isRoleReserved(role)) + ); + }); + }; + + private onIncludeReservedRolesChange = (e: EuiSwitchEvent) => { + this.setState({ + includeReservedRoles: e.target.checked, + visibleRoles: this.getVisibleRoles(this.state.roles, this.state.filter, e.target.checked), + }); + }; + + private getRoleStatusBadges = (role: Role) => { + const enabled = isRoleEnabled(role); + const deprecated = isRoleDeprecated(role); + const reserved = isRoleReserved(role); - return filter - ? roles.filter(({ name }) => { - const normalized = `${name}`.toLowerCase(); - const normalizedQuery = filter.toLowerCase(); - return normalized.indexOf(normalizedQuery) !== -1; - }) - : roles; + const badges = []; + if (!enabled) { + badges.push(); + } + if (reserved) { + badges.push( + + } + /> + ); + } + if (deprecated) { + badges.push( + + ); + } + + return ( + + {badges.map((badge, index) => ( + + {badge} + + ))} + + ); }; private handleDelete = () => { @@ -283,7 +330,14 @@ export class RolesGridPage extends Component { try { const roles = await this.props.rolesAPIClient.getRoles(); - this.setState({ roles }); + this.setState({ + roles, + visibleRoles: this.getVisibleRoles( + roles, + this.state.filter, + this.state.includeReservedRoles + ), + }); } catch (e) { if (_.get(e, 'body.statusCode') === 403) { this.setState({ permissionDenied: true }); @@ -320,6 +374,21 @@ export class RolesGridPage extends Component { ); } + private renderToolsRight() { + return ( + + } + checked={this.state.includeReservedRoles} + onChange={this.onIncludeReservedRolesChange} + /> + ); + } private onCancelDelete = () => { this.setState({ showDeleteConfirmation: false }); }; diff --git a/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.test.tsx b/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.test.tsx index be46612767a59..d41a05e00e53c 100644 --- a/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.test.tsx +++ b/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.test.tsx @@ -40,7 +40,7 @@ describe('', () => { ); @@ -68,7 +68,7 @@ describe('', () => { user={user} isUserChangingOwnPassword={true} onChangePassword={callback} - apiClient={apiClientMock} + userAPIClient={apiClientMock} notifications={coreMock.createStart().notifications} /> ); @@ -107,7 +107,7 @@ describe('', () => { ); diff --git a/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.tsx b/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.tsx index 75621762b1b85..047cad7bead81 100644 --- a/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.tsx +++ b/x-pack/plugins/security/public/management/users/components/change_password_form/change_password_form.tsx @@ -23,7 +23,7 @@ interface Props { user: User; isUserChangingOwnPassword: boolean; onChangePassword?: () => void; - apiClient: PublicMethodsOf; + userAPIClient: PublicMethodsOf; notifications: NotificationsStart; } @@ -279,7 +279,7 @@ export class ChangePasswordForm extends Component { private performPasswordChange = async () => { try { - await this.props.apiClient.changePassword( + await this.props.userAPIClient.changePassword( this.props.user.username, this.state.newPassword, this.state.currentPassword diff --git a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.test.tsx b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.test.tsx index bcec707b03f93..9c5a8b0b75ead 100644 --- a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.test.tsx +++ b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.test.tsx @@ -15,7 +15,7 @@ describe('ConfirmDeleteUsers', () => { it('renders a warning for a single user', () => { const wrapper = mountWithIntl( { it('renders a warning for a multiple users', () => { const wrapper = mountWithIntl( { const onCancel = jest.fn(); const wrapper = mountWithIntl( { const wrapper = mountWithIntl( @@ -90,7 +90,7 @@ describe('ConfirmDeleteUsers', () => { const wrapper = mountWithIntl( diff --git a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx index b7269e0168d7d..53acbf42273e8 100644 --- a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx +++ b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx @@ -13,7 +13,7 @@ import { UserAPIClient } from '../..'; interface Props { usersToDelete: string[]; - apiClient: PublicMethodsOf; + userAPIClient: PublicMethodsOf; notifications: NotificationsStart; onCancel: () => void; callback?: (usersToDelete: string[], errors: string[]) => void; @@ -77,11 +77,11 @@ export class ConfirmDeleteUsers extends Component { } private deleteUsers = () => { - const { usersToDelete, callback, apiClient, notifications } = this.props; + const { usersToDelete, callback, userAPIClient, notifications } = this.props; const errors: string[] = []; usersToDelete.forEach(async username => { try { - await apiClient.deleteUser(username); + await userAPIClient.deleteUser(username); notifications.toasts.addSuccess( i18n.translate( 'xpack.security.management.users.confirmDelete.userSuccessfullyDeletedNotificationMessage', diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index 7a7542909431f..be7517ff892b5 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -15,13 +15,14 @@ import { mockAuthenticatedUser } from '../../../../common/model/authenticated_us import { securityMock } from '../../../mocks'; import { rolesAPIClientMock } from '../../roles/index.mock'; import { userAPIClientMock } from '../index.mock'; +import { findTestSubject } from 'test_utils/find_test_subject'; -const createUser = (username: string) => { +const createUser = (username: string, roles = ['idk', 'something']) => { const user: User = { username, full_name: 'my full name', email: 'foo@bar.com', - roles: ['idk', 'something'], + roles, enabled: true, }; @@ -34,9 +35,9 @@ const createUser = (username: string) => { return user; }; -const buildClients = () => { +const buildClients = (user: User) => { const apiClient = userAPIClientMock.create(); - apiClient.getUser.mockImplementation(async (username: string) => createUser(username)); + apiClient.getUser.mockResolvedValue(user); const rolesAPIClient = rolesAPIClientMock.create(); rolesAPIClient.getRoles.mockImplementation(() => { @@ -59,6 +60,18 @@ const buildClients = () => { }, kibana: [], }, + { + name: 'deprecated-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: ['bar'], + }, + kibana: [], + metadata: { + _deprecated: true, + }, + }, ] as Role[]); }); @@ -83,12 +96,13 @@ function expectMissingSaveButton(wrapper: ReactWrapper) { describe('EditUserPage', () => { it('allows reserved users to be viewed', async () => { - const { apiClient, rolesAPIClient } = buildClients(); + const user = createUser('reserved_user'); + const { apiClient, rolesAPIClient } = buildClients(user); const securitySetup = buildSecuritySetup(); const wrapper = mountWithIntl( { }); it('allows new users to be created', async () => { - const { apiClient, rolesAPIClient } = buildClients(); + const user = createUser(''); + const { apiClient, rolesAPIClient } = buildClients(user); const securitySetup = buildSecuritySetup(); const wrapper = mountWithIntl( { }); it('allows existing users to be edited', async () => { - const { apiClient, rolesAPIClient } = buildClients(); + const user = createUser('existing_user'); + const { apiClient, rolesAPIClient } = buildClients(user); const securitySetup = buildSecuritySetup(); const wrapper = mountWithIntl( { expect(apiClient.getUser).toBeCalledTimes(1); expect(securitySetup.authc.getCurrentUser).toBeCalledTimes(1); + expect(findTestSubject(wrapper, 'hasDeprecatedRolesAssignedHelpText')).toHaveLength(0); expectSaveButton(wrapper); }); + + it('warns when user is assigned a deprecated role', async () => { + const user = createUser('existing_user', ['deprecated-role']); + const { apiClient, rolesAPIClient } = buildClients(user); + const securitySetup = buildSecuritySetup(); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + expect(apiClient.getUser).toBeCalledTimes(1); + expect(securitySetup.authc.getCurrentUser).toBeCalledTimes(1); + + expect(findTestSubject(wrapper, 'hasDeprecatedRolesAssignedHelpText')).toHaveLength(1); + }); }); async function waitForRender(wrapper: ReactWrapper) { diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index 8e7d9fb2dac08..6417ce81b647d 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -18,7 +18,6 @@ import { EuiIcon, EuiText, EuiFieldText, - EuiComboBox, EuiPageContent, EuiPageContentHeader, EuiPageContentHeaderSection, @@ -29,17 +28,18 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NotificationsStart } from 'src/core/public'; -import { User, EditUser, Role } from '../../../../common/model'; +import { User, EditUser, Role, isRoleDeprecated } from '../../../../common/model'; import { AuthenticationServiceSetup } from '../../../authentication'; import { USERS_PATH } from '../../management_urls'; import { RolesAPIClient } from '../../roles'; import { ConfirmDeleteUsers, ChangePasswordForm } from '../components'; import { UserValidator, UserValidationResult } from './validate_user'; +import { RoleComboBox } from '../../role_combo_box'; import { UserAPIClient } from '..'; interface Props { username?: string; - apiClient: PublicMethodsOf; + userAPIClient: PublicMethodsOf; rolesAPIClient: PublicMethodsOf; authc: AuthenticationServiceSetup; notifications: NotificationsStart; @@ -53,7 +53,7 @@ interface State { showDeleteConfirmation: boolean; user: EditUser; roles: Role[]; - selectedRoles: Array<{ label: string }>; + selectedRoles: string[]; formError: UserValidationResult | null; } @@ -99,12 +99,12 @@ export class EditUserPage extends Component { } private async setCurrentUser() { - const { username, apiClient, rolesAPIClient, notifications, authc } = this.props; + const { username, userAPIClient, rolesAPIClient, notifications, authc } = this.props; let { user, currentUser } = this.state; if (username) { try { user = { - ...(await apiClient.getUser(username)), + ...(await userAPIClient.getUser(username)), password: '', confirmPassword: '', }; @@ -138,7 +138,7 @@ export class EditUserPage extends Component { currentUser, user, roles, - selectedRoles: user.roles.map(role => ({ label: role })) || [], + selectedRoles: user.roles || [], }); } @@ -160,18 +160,16 @@ export class EditUserPage extends Component { this.setState({ formError: null, }); - const { apiClient } = this.props; + const { userAPIClient } = this.props; const { user, isNewUser, selectedRoles } = this.state; const userToSave: EditUser = { ...user }; if (!isNewUser) { delete userToSave.password; } delete userToSave.confirmPassword; - userToSave.roles = selectedRoles.map(selectedRole => { - return selectedRole.label; - }); + userToSave.roles = [...selectedRoles]; try { - await apiClient.saveUser(userToSave); + await userAPIClient.saveUser(userToSave); this.props.notifications.toasts.addSuccess( i18n.translate( 'xpack.security.management.users.editUser.userSuccessfullySavedNotificationMessage', @@ -269,7 +267,7 @@ export class EditUserPage extends Component { user={this.state.user} isUserChangingOwnPassword={userIsLoggedInUser} onChangePassword={this.toggleChangePasswordForm} - apiClient={this.props.apiClient} + userAPIClient={this.props.userAPIClient} notifications={this.props.notifications} /> @@ -346,7 +344,7 @@ export class EditUserPage extends Component { }); }; - private onRolesChange = (selectedRoles: Array<{ label: string }>) => { + private onRolesChange = (selectedRoles: string[]) => { this.setState({ selectedRoles, }); @@ -365,8 +363,8 @@ export class EditUserPage extends Component { public render() { const { user, - roles, selectedRoles, + roles, showChangePasswordForm, isNewUser, showDeleteConfirmation, @@ -380,6 +378,22 @@ export class EditUserPage extends Component { return null; } + const hasAnyDeprecatedRolesAssigned = selectedRoles.some(selected => { + const role = roles.find(r => r.name === selected); + return role && isRoleDeprecated(role); + }); + + const roleHelpText = hasAnyDeprecatedRolesAssigned ? ( + + + + ) : ( + undefined + ); + return (
@@ -426,7 +440,7 @@ export class EditUserPage extends Component { onCancel={this.onCancelDelete} usersToDelete={[user.username]} callback={this.handleDelete} - apiClient={this.props.apiClient} + userAPIClient={this.props.userAPIClient} notifications={this.props.notifications} /> ) : null} @@ -492,19 +506,13 @@ export class EditUserPage extends Component { 'xpack.security.management.users.editUser.rolesFormRowLabel', { defaultMessage: 'Roles' } )} + helpText={roleHelpText} > - { - return { 'data-test-subj': `roleOption-${role.name}`, label: role.name }; - })} - selectedOptions={selectedRoles} /> diff --git a/x-pack/plugins/security/public/management/users/user_utils.ts b/x-pack/plugins/security/public/management/users/user_utils.ts new file mode 100644 index 0000000000000..f46f6f897e23b --- /dev/null +++ b/x-pack/plugins/security/public/management/users/user_utils.ts @@ -0,0 +1,9 @@ +/* + * 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 { User } from '../../../common/model'; + +export const isUserReserved = (user: User) => user.metadata?._reserved ?? false; diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index def0649953437..031b67d5d9122 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -5,12 +5,15 @@ */ import { User } from '../../../../common/model'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { UsersGridPage } from './users_grid_page'; import React from 'react'; import { ReactWrapper } from 'enzyme'; import { userAPIClientMock } from '../index.mock'; import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { rolesAPIClientMock } from '../../roles/index.mock'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { EuiBasicTable } from '@elastic/eui'; describe('UsersGridPage', () => { it('renders the list of users', async () => { @@ -39,7 +42,8 @@ describe('UsersGridPage', () => { const wrapper = mountWithIntl( ); @@ -49,6 +53,7 @@ describe('UsersGridPage', () => { expect(apiClientMock.getUsers).toBeCalledTimes(1); expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); expect(wrapper.find('EuiTableRow')).toHaveLength(2); + expect(findTestSubject(wrapper, 'userDisabled')).toHaveLength(0); }); it('renders a forbidden message if user is not authorized', async () => { @@ -56,7 +61,11 @@ describe('UsersGridPage', () => { apiClient.getUsers.mockRejectedValue({ body: { statusCode: 403 } }); const wrapper = mountWithIntl( - + ); await waitForRender(wrapper); @@ -65,10 +74,172 @@ describe('UsersGridPage', () => { expect(wrapper.find('[data-test-subj="permissionDeniedMessage"]')).toHaveLength(1); expect(wrapper.find('EuiInMemoryTable')).toHaveLength(0); }); + + it('renders disabled users', async () => { + const apiClientMock = userAPIClientMock.create(); + apiClientMock.getUsers.mockImplementation(() => { + return Promise.resolve([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: false, + }, + ]); + }); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + expect(findTestSubject(wrapper, 'userDisabled')).toHaveLength(1); + }); + + it('renders a warning when a user is assigned a deprecated role', async () => { + const apiClientMock = userAPIClientMock.create(); + apiClientMock.getUsers.mockImplementation(() => { + return Promise.resolve([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + }, + { + username: 'reserved', + email: 'reserved@bar.net', + full_name: '', + roles: ['superuser'], + enabled: true, + metadata: { + _reserved: true, + }, + }, + ]); + }); + + const roleAPIClientMock = rolesAPIClientMock.create(); + roleAPIClientMock.getRoles.mockResolvedValue([ + { + name: 'kibana_user', + metadata: { + _deprecated: true, + _deprecated_reason: `I don't like you.`, + }, + }, + ]); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + const deprecationTooltip = wrapper.find('[data-test-subj="roleDeprecationTooltip"]').props(); + + expect(deprecationTooltip).toMatchInlineSnapshot(` + Object { + "children":
+ kibana_user + + +
, + "content": "The kibana_user role is deprecated. I don't like you.", + "data-test-subj": "roleDeprecationTooltip", + "delay": "regular", + "position": "top", + } + `); + }); + + it('hides reserved users when instructed to', async () => { + const apiClientMock = userAPIClientMock.create(); + apiClientMock.getUsers.mockImplementation(() => { + return Promise.resolve([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + }, + { + username: 'reserved', + email: 'reserved@bar.net', + full_name: '', + roles: ['superuser'], + enabled: true, + metadata: { + _reserved: true, + }, + }, + ]); + }); + + const roleAPIClientMock = rolesAPIClientMock.create(); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + expect(wrapper.find(EuiBasicTable).props().items).toEqual([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + }, + { + username: 'reserved', + email: 'reserved@bar.net', + full_name: '', + roles: ['superuser'], + enabled: true, + metadata: { + _reserved: true, + }, + }, + ]); + + findTestSubject(wrapper, 'showReservedUsersSwitch').simulate('click'); + + expect(wrapper.find(EuiBasicTable).props().items).toEqual([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + }, + ]); + }); }); async function waitForRender(wrapper: ReactWrapper) { - await Promise.resolve(); - await Promise.resolve(); + await nextTick(); wrapper.update(); } diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx index fa15c3388fcc9..6837fcf430fe7 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import { EuiButton, - EuiIcon, EuiLink, EuiFlexGroup, EuiInMemoryTable, @@ -18,25 +17,36 @@ import { EuiPageContentBody, EuiEmptyPrompt, EuiBasicTableColumn, + EuiSwitchEvent, + EuiSwitch, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NotificationsStart } from 'src/core/public'; -import { User } from '../../../../common/model'; +import { User, Role } from '../../../../common/model'; import { ConfirmDeleteUsers } from '../components'; +import { isUserReserved } from '../user_utils'; +import { DisabledBadge, ReservedBadge } from '../../badges'; +import { RoleTableDisplay } from '../../role_table_display'; +import { RolesAPIClient } from '../../roles'; import { UserAPIClient } from '..'; interface Props { - apiClient: PublicMethodsOf; + userAPIClient: PublicMethodsOf; + rolesAPIClient: PublicMethodsOf; notifications: NotificationsStart; } interface State { users: User[]; + visibleUsers: User[]; + roles: null | Role[]; selection: User[]; showDeleteConfirmation: boolean; permissionDenied: boolean; filter: string; + includeReservedUsers: boolean; } export class UsersGridPage extends Component { @@ -44,19 +54,22 @@ export class UsersGridPage extends Component { super(props); this.state = { users: [], + visibleUsers: [], + roles: [], selection: [], showDeleteConfirmation: false, permissionDenied: false, filter: '', + includeReservedUsers: true, }; } public componentDidMount() { - this.loadUsers(); + this.loadUsersAndRoles(); } public render() { - const { users, filter, permissionDenied, showDeleteConfirmation, selection } = this.state; + const { users, roles, permissionDenied, showDeleteConfirmation, selection } = this.state; if (permissionDenied) { return ( @@ -86,17 +99,6 @@ export class UsersGridPage extends Component { } const path = '#/management/security/'; const columns: Array> = [ - { - field: 'full_name', - name: i18n.translate('xpack.security.management.users.fullNameColumnName', { - defaultMessage: 'Full Name', - }), - sortable: true, - truncateText: true, - render: (fullName: string) => { - return
{fullName}
; - }, - }, { field: 'username', name: i18n.translate('xpack.security.management.users.userNameColumnName', { @@ -110,6 +112,18 @@ export class UsersGridPage extends Component { ), }, + { + field: 'full_name', + name: i18n.translate('xpack.security.management.users.fullNameColumnName', { + defaultMessage: 'Full Name', + }), + sortable: true, + truncateText: true, + render: (fullName: string) => { + return
{fullName}
; + }, + }, + { field: 'email', name: i18n.translate('xpack.security.management.users.emailAddressColumnName', { @@ -126,34 +140,27 @@ export class UsersGridPage extends Component { name: i18n.translate('xpack.security.management.users.rolesColumnName', { defaultMessage: 'Roles', }), + width: '30%', render: (rolenames: string[]) => { const roleLinks = rolenames.map((rolename, index) => { - return ( - - {rolename} - {index === rolenames.length - 1 ? null : ', '} - - ); + const roleDefinition = roles?.find(role => role.name === rolename) ?? rolename; + return ; }); return
{roleLinks}
; }, }, { field: 'metadata', - name: i18n.translate('xpack.security.management.users.reservedColumnName', { - defaultMessage: 'Reserved', + name: i18n.translate('xpack.security.management.users.statusColumnName', { + defaultMessage: 'Status', }), + width: '10%', sortable: ({ metadata }: User) => Boolean(metadata && metadata._reserved), - width: '100px', - align: 'right', description: i18n.translate('xpack.security.management.users.reservedColumnDescription', { defaultMessage: 'Reserved users are built-in and cannot be removed. Only the password can be changed.', }), - render: (metadata: User['metadata']) => - metadata && metadata._reserved ? ( - - ) : null, + render: (metadata: User['metadata'], record: User) => this.getUserStatusBadges(record), }, ]; const pagination = { @@ -170,18 +177,24 @@ export class UsersGridPage extends Component { }; const search = { toolsLeft: this.renderToolsLeft(), + toolsRight: this.renderToolsRight(), box: { incremental: true, }, onChange: (query: any) => { this.setState({ filter: query.queryText, + visibleUsers: this.getVisibleUsers( + this.state.users, + query.queryText, + this.state.includeReservedUsers + ), }); }, }; const sorting = { sort: { - field: 'full_name', + field: 'username', direction: 'asc', }, } as const; @@ -190,13 +203,7 @@ export class UsersGridPage extends Component { 'data-test-subj': 'userRow', }; }; - const usersToShow = filter - ? users.filter(({ username, roles, full_name: fullName = '', email = '' }) => { - const normalized = `${username} ${roles.join(' ')} ${fullName} ${email}`.toLowerCase(); - const normalizedQuery = filter.toLowerCase(); - return normalized.indexOf(normalizedQuery) !== -1; - }) - : users; + return (
@@ -226,7 +233,7 @@ export class UsersGridPage extends Component { onCancel={this.onCancelDelete} usersToDelete={selection.map(user => user.username)} callback={this.handleDelete} - apiClient={this.props.apiClient} + userAPIClient={this.props.userAPIClient} notifications={this.props.notifications} /> ) : null} @@ -237,7 +244,7 @@ export class UsersGridPage extends Component { columns={columns} selection={selectionConfig} pagination={pagination} - items={usersToShow} + items={this.state.visibleUsers} loading={users.length === 0} search={search} sorting={sorting} @@ -262,10 +269,34 @@ export class UsersGridPage extends Component { }); }; - private async loadUsers() { + private getVisibleUsers = (users: User[], filter: string, includeReservedUsers: boolean) => { + return users.filter( + ({ username, roles: userRoles, full_name: fullName = '', email = '', metadata = {} }) => { + const normalized = `${username} ${userRoles.join(' ')} ${fullName} ${email}`.toLowerCase(); + const normalizedQuery = filter.toLowerCase(); + return ( + normalized.indexOf(normalizedQuery) !== -1 && + (includeReservedUsers || !metadata._reserved) + ); + } + ); + }; + + private async loadUsersAndRoles() { try { - const users = await this.props.apiClient.getUsers(); - this.setState({ users }); + const [users, roles] = await Promise.all([ + this.props.userAPIClient.getUsers(), + this.props.rolesAPIClient.getRoles(), + ]); + this.setState({ + users, + roles, + visibleUsers: this.getVisibleUsers( + users, + this.state.filter, + this.state.includeReservedUsers + ), + }); } catch (e) { if (e.body.statusCode === 403) { this.setState({ permissionDenied: true }); @@ -303,6 +334,62 @@ export class UsersGridPage extends Component { ); } + private onIncludeReservedUsersChange = (e: EuiSwitchEvent) => { + this.setState({ + includeReservedUsers: e.target.checked, + visibleUsers: this.getVisibleUsers(this.state.users, this.state.filter, e.target.checked), + }); + }; + + private renderToolsRight() { + return ( + + } + checked={this.state.includeReservedUsers} + onChange={this.onIncludeReservedUsersChange} + /> + ); + } + + private getUserStatusBadges = (user: User) => { + const enabled = user.enabled; + const reserved = isUserReserved(user); + + const badges = []; + if (!enabled) { + badges.push(); + } + if (reserved) { + badges.push( + + } + /> + ); + } + + return ( + + {badges.map((badge, index) => ( + + {badge} + + ))} + + ); + }; + private onCancelDelete = () => { this.setState({ showDeleteConfirmation: false }); }; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index fd81756f176f7..05491d6f889b6 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -58,7 +58,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Users' }]); expect(container).toMatchInlineSnapshot(`
- Users Page: {"notifications":{"toasts":{}},"apiClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}}} + Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}}}
`); @@ -80,7 +80,7 @@ describe('usersManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"apiClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}}}
`); @@ -103,7 +103,7 @@ describe('usersManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"apiClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"someUserName"} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"someUserName"}
`); diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 9aebb396ce9a9..7874b810676b5 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -39,9 +39,16 @@ export const usersManagementApp = Object.freeze({ ]; const userAPIClient = new UserAPIClient(http); + const rolesAPIClient = new RolesAPIClient(http); const UsersGridPageWithBreadcrumbs = () => { setBreadcrumbs(usersBreadcrumbs); - return ; + return ( + + ); }; const EditUserPageWithBreadcrumbs = () => { @@ -61,7 +68,7 @@ export const usersManagementApp = Object.freeze({ return ( ), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0504343e4dcc3..43f2e1956adf3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10662,14 +10662,10 @@ "xpack.security.management.roles.deleteSelectedRolesButtonLabel": "ロール {numSelected} {numSelected, plural, one { } other {}} を削除しました", "xpack.security.management.roles.deletingRolesWarningMessage": "この操作は元に戻すことができません。", "xpack.security.management.roles.deniedPermissionTitle": "ロールを管理するにはパーミッションが必要です", - "xpack.security.management.roles.disabledTooltip": " (無効)", "xpack.security.management.roles.editRoleActionName": "{roleName} を編集", "xpack.security.management.roles.fetchingRolesErrorMessage": "ロールの取得中にエラーが発生: {message}", "xpack.security.management.roles.nameColumnName": "ロール", "xpack.security.management.roles.noPermissionToManageRolesDescription": "システム管理者にお問い合わせください。", - "xpack.security.management.roles.reservedColumnDescription": "リザーブされたロールはビルトインのため削除または変更できません。", - "xpack.security.management.roles.reservedColumnName": "リザーブ", - "xpack.security.management.roles.reservedRoleIconLabel": "指定済みロール", "xpack.security.management.roles.roleNotFound": "「{roleName}」ロールが見つかりません。", "xpack.security.management.roles.roleTitle": "ロール", "xpack.security.management.roles.subtitle": "ユーザーのグループにロールを適用してスタック全体のパーミッションを管理", @@ -10720,7 +10716,6 @@ "xpack.security.management.users.fullNameColumnName": "フルネーム", "xpack.security.management.users.permissionDeniedToManageUsersDescription": "システム管理者にお問い合わせください。", "xpack.security.management.users.reservedColumnDescription": "リザーブされたユーザーはビルトインのため削除できません。パスワードのみ変更できます。", - "xpack.security.management.users.reservedColumnName": "リザーブ", "xpack.security.management.users.rolesColumnName": "ロール", "xpack.security.management.users.userNameColumnName": "ユーザー名", "xpack.security.management.users.usersTitle": "ユーザー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 156b1d3d24153..328afa513337b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10662,14 +10662,10 @@ "xpack.security.management.roles.deleteSelectedRolesButtonLabel": "删除 {numSelected} 个角色{numSelected, plural, one {} other {}}", "xpack.security.management.roles.deletingRolesWarningMessage": "此操作无法撤消。", "xpack.security.management.roles.deniedPermissionTitle": "您需要用于管理角色的权限", - "xpack.security.management.roles.disabledTooltip": " (已禁用)", "xpack.security.management.roles.editRoleActionName": "编辑 {roleName}", "xpack.security.management.roles.fetchingRolesErrorMessage": "获取用户时出错:{message}", "xpack.security.management.roles.nameColumnName": "角色", "xpack.security.management.roles.noPermissionToManageRolesDescription": "请联系您的管理员。", - "xpack.security.management.roles.reservedColumnDescription": "保留角色为内置角色,不能编辑或移除。", - "xpack.security.management.roles.reservedColumnName": "保留", - "xpack.security.management.roles.reservedRoleIconLabel": "保留角色", "xpack.security.management.roles.roleNotFound": "未找到任何“{roleName}”。", "xpack.security.management.roles.roleTitle": "角色", "xpack.security.management.roles.subtitle": "将角色应用到用户组并管理整个堆栈的权限。", @@ -10720,7 +10716,6 @@ "xpack.security.management.users.fullNameColumnName": "全名", "xpack.security.management.users.permissionDeniedToManageUsersDescription": "请联系您的管理员。", "xpack.security.management.users.reservedColumnDescription": "保留的用户是内置的,无法删除。只能更改密码。", - "xpack.security.management.users.reservedColumnName": "保留", "xpack.security.management.users.rolesColumnName": "角色", "xpack.security.management.users.userNameColumnName": "用户名", "xpack.security.management.users.usersTitle": "用户", diff --git a/x-pack/test/functional/apps/security/role_mappings.ts b/x-pack/test/functional/apps/security/role_mappings.ts index a1517e1934a28..827466c660015 100644 --- a/x-pack/test/functional/apps/security/role_mappings.ts +++ b/x-pack/test/functional/apps/security/role_mappings.ts @@ -28,7 +28,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('allows a role mapping to be created', async () => { await testSubjects.click('createRoleMappingButton'); await testSubjects.setValue('roleMappingFormNameInput', 'new_role_mapping'); - await testSubjects.setValue('roleMappingFormRoleComboBox', 'superuser'); + await testSubjects.setValue('rolesDropdown', 'superuser'); await browser.pressKeys(browser.keys.ENTER); await testSubjects.click('roleMappingsAddRuleButton'); diff --git a/x-pack/test/functional/apps/security/users.js b/x-pack/test/functional/apps/security/users.js index 9dc42553f0fdf..f49a74a661a63 100644 --- a/x-pack/test/functional/apps/security/users.js +++ b/x-pack/test/functional/apps/security/users.js @@ -82,13 +82,34 @@ export default function({ getService, getPageObjects }) { log.debug('actualRoles = %j', roles); // This only contains the first page of alphabetically sorted results, so the assertions are only for the first handful of expected roles. expect(roles.apm_system.reserved).to.be(true); + expect(roles.apm_system.deprecated).to.be(false); + expect(roles.apm_user.reserved).to.be(true); + expect(roles.apm_user.deprecated).to.be(false); + expect(roles.beats_admin.reserved).to.be(true); + expect(roles.beats_admin.deprecated).to.be(false); + expect(roles.beats_system.reserved).to.be(true); + expect(roles.beats_system.deprecated).to.be(false); + expect(roles.kibana_admin.reserved).to.be(true); + expect(roles.kibana_admin.deprecated).to.be(false); + + expect(roles.kibana_user.reserved).to.be(true); + expect(roles.kibana_user.deprecated).to.be(true); + + expect(roles.kibana_dashboard_only_user.reserved).to.be(true); + expect(roles.kibana_dashboard_only_user.deprecated).to.be(true); + expect(roles.kibana_system.reserved).to.be(true); + expect(roles.kibana_system.deprecated).to.be(false); + expect(roles.logstash_system.reserved).to.be(true); + expect(roles.logstash_system.deprecated).to.be(false); + expect(roles.monitoring_user.reserved).to.be(true); + expect(roles.monitoring_user.deprecated).to.be(false); }); }); } diff --git a/x-pack/test/functional/page_objects/security_page.js b/x-pack/test/functional/page_objects/security_page.js index 5889a374e443e..4803596b973bc 100644 --- a/x-pack/test/functional/page_objects/security_page.js +++ b/x-pack/test/functional/page_objects/security_page.js @@ -232,16 +232,16 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const usernameElement = await user.findByCssSelector('[data-test-subj="userRowUserName"]'); const emailElement = await user.findByCssSelector('[data-test-subj="userRowEmail"]'); const rolesElement = await user.findByCssSelector('[data-test-subj="userRowRoles"]'); - const isReservedElementVisible = await user.findByCssSelector('td:last-child'); + // findAllByCssSelector is substantially faster than `find.descendantExistsByCssSelector for negative cases + const isUserReserved = + (await user.findAllByCssSelector('span[data-test-subj="userReserved"]', 1)).length > 0; return { username: await usernameElement.getVisibleText(), fullname: await fullnameElement.getVisibleText(), email: await emailElement.getVisibleText(), - roles: (await rolesElement.getVisibleText()).split(',').map(role => role.trim()), - reserved: (await isReservedElementVisible.getAttribute('innerHTML')).includes( - 'reservedUser' - ), + roles: (await rolesElement.getVisibleText()).split('\n').map(role => role.trim()), + reserved: isUserReserved, }; }); } @@ -249,15 +249,22 @@ export function SecurityPageProvider({ getService, getPageObjects }) { async getElasticsearchRoles() { const users = await testSubjects.findAll('roleRow'); return mapAsync(users, async role => { - const rolenameElement = await role.findByCssSelector('[data-test-subj="roleRowName"]'); - const reservedRoleRow = await role.findByCssSelector('td:nth-last-child(2)'); + const [rolename, reserved, deprecated] = await Promise.all([ + role.findByCssSelector('[data-test-subj="roleRowName"]').then(el => el.getVisibleText()), + // findAllByCssSelector is substantially faster than `find.descendantExistsByCssSelector for negative cases + role + .findAllByCssSelector('span[data-test-subj="roleReserved"]', 1) + .then(el => el.length > 0), + // findAllByCssSelector is substantially faster than `find.descendantExistsByCssSelector for negative cases + role + .findAllByCssSelector('span[data-test-subj="roleDeprecated"]', 1) + .then(el => el.length > 0), + ]); return { - rolename: await rolenameElement.getVisibleText(), - reserved: await find.descendantExistsByCssSelector( - '[data-test-subj="reservedRole"]', - reservedRoleRow - ), + rolename, + reserved, + deprecated, }; }); } @@ -400,7 +407,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { } async selectRole(role) { - const dropdown = await testSubjects.find('userFormRolesDropdown'); + const dropdown = await testSubjects.find('rolesDropdown'); const input = await dropdown.findByCssSelector('input'); await input.type(role); await testSubjects.click(`roleOption-${role}`);