Add support for deprecated roles (#57209) (#59197)

* Add support for deprecated roles

* address PR feedback

* remove unused import

* copy edits

* fix snapshots

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Larry Gregory 2020-03-03 15:44:45 -05:00 committed by GitHub
parent 95cf5f9c98
commit 4e624b2b2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1477 additions and 304 deletions

View file

@ -117,7 +117,8 @@ cluster alert notifications from Monitoring.
==== Dashboard
[horizontal]
`xpackDashboardMode:roles`:: The roles that belong to <<xpack-dashboard-only-mode, dashboard only mode>>.
`xpackDashboardMode:roles`:: **Deprecated. Use <<kibana-feature-privileges,feature privileges>> instead.**
The roles that belong to <<xpack-dashboard-only-mode, dashboard only mode>>.
[float]
[[kibana-discover-settings]]

View file

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

View file

@ -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: {

View file

@ -15,10 +15,12 @@ export {
RoleIndexPrivilege,
RoleKibanaPrivilege,
copyRole,
isReadOnlyRole,
isReservedRole,
isRoleDeprecated,
isRoleReadOnly,
isRoleReserved,
isRoleEnabled,
prepareRoleClone,
getExtendedRoleDeprecationNotice,
} from './role';
export { KibanaPrivileges } from './kibana_privileges';
export {

View file

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

View file

@ -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<Role>) {
*
* @param role Role as returned by roles API
*/
export function isReservedRole(role: Partial<Role>) {
export function isRoleReserved(role: Partial<Role>) {
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<Role>) {
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<Role>) {
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<Role>): boolean {
return isReservedRole(role) || (role._transform_error?.length ?? 0) > 0;
export function isRoleReadOnly(role: Partial<Role>): 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<Role>) {
return role.metadata?._deprecated_reason ?? '';
}

View file

@ -48,7 +48,7 @@ describe('<AccountManagementPage>', () => {
<AccountManagementPage
authc={getSecuritySetupMock({ currentUser: user }).authc}
notifications={coreMock.createStart().notifications}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
/>
);
@ -70,7 +70,7 @@ describe('<AccountManagementPage>', () => {
<AccountManagementPage
authc={getSecuritySetupMock({ currentUser: user }).authc}
notifications={coreMock.createStart().notifications}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
/>
);
@ -88,7 +88,7 @@ describe('<AccountManagementPage>', () => {
<AccountManagementPage
authc={getSecuritySetupMock({ currentUser: user }).authc}
notifications={coreMock.createStart().notifications}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
/>
);
@ -106,7 +106,7 @@ describe('<AccountManagementPage>', () => {
<AccountManagementPage
authc={getSecuritySetupMock({ currentUser: user }).authc}
notifications={coreMock.createStart().notifications}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
/>
);
@ -125,7 +125,7 @@ describe('<AccountManagementPage>', () => {
<AccountManagementPage
authc={getSecuritySetupMock({ currentUser: user }).authc}
notifications={coreMock.createStart().notifications}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
/>
);

View file

@ -14,11 +14,11 @@ import { PersonalInfo } from './personal_info';
interface Props {
authc: AuthenticationServiceSetup;
apiClient: PublicMethodsOf<UserAPIClient>;
userAPIClient: PublicMethodsOf<UserAPIClient>;
notifications: NotificationsStart;
}
export const AccountManagementPage = ({ apiClient, authc, notifications }: Props) => {
export const AccountManagementPage = ({ userAPIClient, authc, notifications }: Props) => {
const [currentUser, setCurrentUser] = useState<AuthenticatedUser | null>(null);
useEffect(() => {
authc.getCurrentUser().then(setCurrentUser);
@ -40,7 +40,11 @@ export const AccountManagementPage = ({ apiClient, authc, notifications }: Props
<PersonalInfo user={currentUser} />
<ChangePassword user={currentUser} apiClient={apiClient} notifications={notifications} />
<ChangePassword
user={currentUser}
userAPIClient={userAPIClient}
notifications={notifications}
/>
</EuiPanel>
</EuiPageBody>
</EuiPage>

View file

@ -13,7 +13,7 @@ import { ChangePasswordForm } from '../../management/users/components/change_pas
interface Props {
user: AuthenticatedUser;
apiClient: PublicMethodsOf<UserAPIClient>;
userAPIClient: PublicMethodsOf<UserAPIClient>;
notifications: NotificationsSetup;
}
@ -48,7 +48,7 @@ export class ChangePassword extends Component<Props, {}> {
<ChangePasswordForm
user={this.props.user}
isUserChangingOwnPassword={true}
apiClient={this.props.apiClient}
userAPIClient={this.props.userAPIClient}
notifications={this.props.notifications}
/>
</EuiDescribedFormGroup>

View file

@ -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 (
<OptionalToolTip tooltipContent={props.tooltipContent}>
<EuiBadge data-test-subj={props['data-test-subj']} color="warning">
<FormattedMessage
id="xpack.security.management.deprecatedBadge"
defaultMessage="Deprecated"
/>
</EuiBadge>
</OptionalToolTip>
);
};

View file

@ -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 (
<OptionalToolTip tooltipContent={props.tooltipContent}>
<EuiBadge data-test-subj={props['data-test-subj']} color="hollow">
<FormattedMessage id="xpack.security.management.disabledBadge" defaultMessage="Disabled" />
</EuiBadge>
</OptionalToolTip>
);
};

View file

@ -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 (
<OptionalToolTip tooltipContent={props.tooltipContent}>
<EuiBadge data-test-subj={props['data-test-subj']} color="secondary">
<FormattedMessage id="xpack.security.management.enabledBadge" defaultMessage="Enabled" />
</EuiBadge>
</OptionalToolTip>
);
};

View file

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

View file

@ -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<any>;
tooltipContent?: EuiToolTipProps['content'];
}
export const OptionalToolTip = (props: Props) => {
if (props.tooltipContent) {
return <EuiToolTip content={props.tooltipContent}>{props.children}</EuiToolTip>;
}
return props.children;
};

View file

@ -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 (
<OptionalToolTip tooltipContent={props.tooltipContent}>
<EuiBadge data-test-subj={props['data-test-subj']} color="primary">
<FormattedMessage id="xpack.security.management.reservedBadge" defaultMessage="Reserved" />
</EuiBadge>
</OptionalToolTip>
);
};

View file

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

View file

@ -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(
<RoleComboBox availableRoles={availableRoles} selectedRoleNames={[]} onChange={jest.fn()} />
);
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(
<RoleComboBox availableRoles={availableRoles} selectedRoleNames={[]} onChange={jest.fn()} />
);
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(
<div>
<RoleComboBox availableRoles={availableRoles} selectedRoleNames={[]} onChange={jest.fn()} />
</div>
);
findTestSubject(wrapper, 'comboBoxToggleListButton').simulate('click');
wrapper.find(EuiComboBox).setState({ isListOpen: true });
expect(findTestSubject(wrapper, 'rolesDropdown-renderOption')).toMatchInlineSnapshot(`null`);
});
});

View file

@ -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 (
<EuiComboBox
data-test-subj="rolesDropdown"
placeholder={
props.placeholder ||
i18n.translate('xpack.security.management.users.editUser.addRolesPlaceholder', {
defaultMessage: 'Add roles',
})
}
onChange={onRolesChange}
isLoading={props.isLoading}
isDisabled={props.isDisabled}
options={options}
selectedOptions={selectedOptions}
renderOption={option => <RoleComboBoxOption option={option} />}
/>
);
};

View file

@ -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(
<RoleComboBoxOption
option={{
color: 'default',
label: 'role-1',
}}
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiText
color="default"
data-test-subj="rolesDropdown-renderOption"
>
role-1
</EuiText>
`);
});
it('renders a deprecated role correctly', () => {
const wrapper = shallowWithIntl(
<RoleComboBoxOption
option={{
color: 'warning',
label: 'role-1',
value: {
isDeprecated: true,
},
}}
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiText
color="warning"
data-test-subj="rolesDropdown-renderOption"
>
role-1
(deprecated)
</EuiText>
`);
});
});

View file

@ -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 (
<EuiText color={option.color as any} data-test-subj="rolesDropdown-renderOption">
{option.label} {isDeprecated ? deprecatedLabel : ''}
</EuiText>
);
};

View file

@ -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<RolesAPIClient>;
@ -33,6 +33,7 @@ describe('EditRoleMappingPage', () => {
(rolesAPI as jest.Mocked<RolesAPIClient>).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(
<EditRoleMappingPage
name={'foo'}
roleMappingsAPI={roleMappingsAPI}
rolesAPIClient={rolesAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
/>
);
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({

View file

@ -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<Props, State> {
>
<EuiSwitch
name={'enabled'}
label={
<FormattedMessage
id="xpack.security.management.editRoleMapping.roleMappingEnabledLabel"
defaultMessage="Enable mapping"
/>
}
label={i18n.translate(
'xpack.security.management.editRoleMapping.roleMappingEnabledLabel',
{
defaultMessage: 'Enable mapping',
}
)}
showLabel={false}
data-test-subj="roleMappingsEnabledSwitch"
checked={this.props.roleMapping.enabled}

View file

@ -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<RolesAPIClient>;
@ -40,7 +42,7 @@ export class RoleSelector extends React.Component<Props, State> {
public render() {
const { mode } = this.props;
return (
<EuiFormRow fullWidth>
<EuiFormRow fullWidth helpText={this.getHelpText()}>
{mode === 'roles' ? this.getRoleComboBox() : this.getRoleTemplates()}
</EuiFormRow>
);
@ -49,19 +51,18 @@ export class RoleSelector extends React.Component<Props, State> {
private getRoleComboBox = () => {
const { roles = [] } = this.props.roleMapping;
return (
<EuiComboBox
data-test-subj="roleMappingFormRoleComboBox"
<RoleComboBox
placeholder={i18n.translate(
'xpack.security.management.editRoleMapping.selectRolesPlaceholder',
{ defaultMessage: 'Select one or more roles' }
)}
isLoading={this.state.roles.length === 0}
options={this.state.roles.map(r => ({ 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<Props, State> {
</div>
);
};
private getHelpText = () => {
if (this.props.mode === 'roles' && this.hasDeprecatedRolesAssigned()) {
return (
<span data-test-subj="deprecatedRolesAssigned">
<FormattedMessage
id="xpack.security.management.editRoleMapping.deprecatedRolesAssigned"
defaultMessage="This mapping is assigned a deprecated role. Please migrate to a supported role."
/>
</span>
);
}
};
private hasDeprecatedRolesAssigned = () => {
return (
this.props.roleMapping.roles?.some(r =>
this.state.roles.some(role => role.name === r && isRoleDeprecated(role))
) ?? false
);
};
}

View file

@ -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(
<RoleMappingsGridPage
rolesAPIClient={rolesAPIClientMock.create()}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
@ -55,6 +57,7 @@ describe('RoleMappingsGridPage', () => {
const { docLinks, notifications } = coreMock.createStart();
const wrapper = mountWithIntl(
<RoleMappingsGridPage
rolesAPIClient={rolesAPIClientMock.create()}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
@ -89,6 +92,7 @@ describe('RoleMappingsGridPage', () => {
const { docLinks, notifications } = coreMock.createStart();
const wrapper = mountWithIntl(
<RoleMappingsGridPage
rolesAPIClient={rolesAPIClientMock.create()}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
@ -104,7 +108,7 @@ describe('RoleMappingsGridPage', () => {
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(
<RoleMappingsGridPage
rolesAPIClient={rolesAPIClientMock.create()}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
@ -155,6 +160,7 @@ describe('RoleMappingsGridPage', () => {
const { docLinks, notifications } = coreMock.createStart();
const wrapper = mountWithIntl(
<RoleMappingsGridPage
rolesAPIClient={rolesAPIClientMock.create()}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
@ -192,6 +198,7 @@ describe('RoleMappingsGridPage', () => {
const { docLinks, notifications } = coreMock.createStart();
const wrapper = mountWithIntl(
<RoleMappingsGridPage
rolesAPIClient={rolesAPIClientMock.create()}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
@ -216,4 +223,70 @@ describe('RoleMappingsGridPage', () => {
// 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(
<RoleMappingsGridPage
rolesAPIClient={roleAPIClient}
roleMappingsAPI={roleMappingsAPI}
notifications={notifications}
docLinks={new DocumentationLinksService(docLinks)}
/>
);
await nextTick();
wrapper.update();
const deprecationTooltip = wrapper.find('[data-test-subj="roleDeprecationTooltip"]').props();
expect(deprecationTooltip).toMatchInlineSnapshot(`
Object {
"children": <div>
kibana_user
<EuiIcon
className="eui-alignTop"
color="warning"
size="s"
type="alert"
/>
</div>,
"content": "The kibana_user role is deprecated. I don't like you.",
"data-test-subj": "roleDeprecationTooltip",
"delay": "regular",
"position": "top",
}
`);
});
});

View file

@ -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<RolesAPIClient>;
roleMappingsAPI: PublicMethodsOf<RoleMappingsAPIClient>;
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<Props, State> {
this.state = {
loadState: 'loadingApp',
roleMappings: null,
roles: null,
hasCompatibleRealms: true,
selectedItems: [],
error: undefined,
@ -308,7 +309,7 @@ export class RoleMappingsGridPage extends Component<Props, State> {
}),
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 (
<span data-test-subj="roleMappingRoles">
@ -322,13 +323,11 @@ export class RoleMappingsGridPage extends Component<Props, State> {
</span>
);
}
const roleLinks = roles.map((rolename, index) => {
return (
<Fragment key={rolename}>
<EuiLink href={getEditRoleHref(rolename)}>{rolename}</EuiLink>
{index === roles.length - 1 ? null : ', '}
</Fragment>
);
const roleLinks = assignedRoleNames.map((rolename, index) => {
const role: Role | string =
this.state.roles?.find(r => r.name === rolename) ?? rolename;
return <RoleTableDisplay role={role} key={rolename} />;
});
return <div data-test-subj="roleMappingRoles">{roleLinks}</div>;
},
@ -341,24 +340,10 @@ export class RoleMappingsGridPage extends Component<Props, State> {
sortable: true,
render: (enabled: boolean) => {
if (enabled) {
return (
<EuiBadge data-test-subj="roleMappingEnabled" color="secondary">
<FormattedMessage
id="xpack.security.management.roleMappings.enabledBadge"
defaultMessage="Enabled"
/>
</EuiBadge>
);
return <EnabledBadge data-test-subj="roleMappingEnabled" />;
}
return (
<EuiBadge color="hollow" data-test-subj="roleMappingEnabled">
<FormattedMessage
id="xpack.security.management.roleMappings.disabledBadge"
defaultMessage="Disabled"
/>
</EuiBadge>
);
return <DisabledBadge data-test-subj="roleMappingEnabled" />;
},
},
{
@ -458,13 +443,27 @@ export class RoleMappingsGridPage extends Component<Props, State> {
});
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();

View file

@ -53,7 +53,7 @@ describe('roleMappingsManagementApp', () => {
expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Role Mappings' }]);
expect(container).toMatchInlineSnapshot(`
<div>
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/"}}
</div>
`);

View file

@ -48,6 +48,7 @@ export const roleMappingsManagementApp = Object.freeze({
return (
<RoleMappingsGridPage
notifications={notifications}
rolesAPIClient={new RolesAPIClient(http)}
roleMappingsAPI={roleMappingsAPIClient}
docLinks={dockLinksService}
/>

View file

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

View file

@ -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 = <div>{role}</div>;
href = getEditRoleHref(role);
} else if (isRoleDeprecated(role)) {
content = (
<EuiToolTip
content={getExtendedRoleDeprecationNotice(role)}
data-test-subj="roleDeprecationTooltip"
>
<div>
{role.name} <EuiIcon type="alert" color="warning" size="s" className={'eui-alignTop'} />
</div>
</EuiToolTip>
);
href = getEditRoleHref(role.name);
} else {
content = <div>{role.name}</div>;
href = getEditRoleHref(role.name);
}
return <EuiLink href={href}>{content}</EuiLink>;
};

View file

@ -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<Props> = ({
}
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<Props> = ({
const props: HTMLProps<HTMLDivElement> = {
tabIndex: 0,
};
if (isReservedRole) {
if (isRoleReserved) {
titleText = (
<FormattedMessage
id="xpack.security.management.editRole.viewingRoleTitle"
@ -343,7 +347,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
};
const getActionButton = () => {
if (isEditingExistingRole && !isReadOnlyRole) {
if (isEditingExistingRole && !isRoleReadOnly) {
return (
<EuiFlexItem grow={false}>
<DeleteRoleButton canDelete={true} onDelete={handleDeleteRole} />
@ -365,7 +369,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
/>
}
helpText={
!isReservedRole && isEditingExistingRole ? (
!isRoleReserved && isEditingExistingRole ? (
<FormattedMessage
id="xpack.security.management.editRole.roleNameFormRowHelpText"
defaultMessage="A role's name cannot be changed once it has been created."
@ -381,7 +385,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
value={role.name || ''}
onChange={onNameChange}
data-test-subj={'roleFormNameInput'}
readOnly={isReservedRole || isEditingExistingRole}
readOnly={isRoleReserved || isEditingExistingRole}
/>
</EuiFormRow>
</EuiPanel>
@ -400,7 +404,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
<EuiSpacer />
<ElasticsearchPrivileges
role={role}
editable={!isReadOnlyRole}
editable={!isRoleReadOnly}
indicesAPIClient={indicesAPIClient}
onChange={onRoleChange}
runAsUsers={runAsUsers}
@ -426,7 +430,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
spacesEnabled={spacesEnabled}
features={features}
uiCapabilities={uiCapabilities}
editable={!isReadOnlyRole}
editable={!isRoleReadOnly}
role={role}
onChange={onRoleChange}
validator={validator}
@ -436,7 +440,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
};
const getFormButtons = () => {
if (isReadOnlyRole) {
if (isRoleReadOnly) {
return getReturnToRoleListButton();
}
@ -479,7 +483,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
data-test-subj={`roleFormSaveButton`}
fill
onClick={saveRole}
disabled={isReservedRole}
disabled={isRoleReserved}
>
{saveText}
</EuiButton>
@ -563,7 +567,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
<EuiText size="s">{description}</EuiText>
{isReservedRole && (
{isRoleReserved && (
<Fragment>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
@ -577,6 +581,17 @@ export const EditRolePage: FunctionComponent<Props> = ({
</Fragment>
)}
{isDeprecatedRole && (
<Fragment>
<EuiSpacer size="s" />
<EuiCallOut
title={getExtendedRoleDeprecationNotice(role)}
color="warning"
iconType="alert"
/>
</Fragment>
)}
<EuiSpacer />
{getRoleName()}

View file

@ -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<Props, {}> {
selectedOptions={selectedOptions}
onChange={this.onClusterPrivilegesChange}
onCreateOption={this.onCreateCustomPrivilege}
isDisabled={isReadOnlyRole(role)}
isDisabled={isRoleReadOnly(role)}
/>
</EuiFlexItem>
);

View file

@ -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(<IndexPrivilegeForm {...testProps} />);
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(<IndexPrivilegeForm {...testProps} />);
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(<IndexPrivilegeForm {...testProps} />);
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(),

View file

@ -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<Props, State> {
<EuiHorizontalRule />
<EuiFlexGroup className="index-privilege-form">
<EuiFlexItem>{this.getPrivilegeForm()}</EuiFlexItem>
{!this.props.isReadOnlyRole && (
{!this.props.isRoleReadOnly && (
<EuiFlexItem grow={false}>
<EuiFormRow hasEmptyLabelSpace>
<EuiButtonIcon
@ -109,7 +109,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
selectedOptions={this.props.indexPrivilege.names.map(toOption)}
onCreateOption={this.onCreateIndexPatternOption}
onChange={this.onIndexPatternsChange}
isDisabled={this.props.isReadOnlyRole}
isDisabled={this.props.isRoleReadOnly}
/>
</EuiFormRow>
</EuiFlexItem>
@ -128,7 +128,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
options={this.props.availableIndexPrivileges.map(toOption)}
selectedOptions={this.props.indexPrivilege.privileges.map(toOption)}
onChange={this.onPrivilegeChange}
isDisabled={this.props.isReadOnlyRole}
isDisabled={this.props.isRoleReadOnly}
/>
</EuiFormRow>
</EuiFlexItem>
@ -149,7 +149,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
allowDocumentLevelSecurity,
availableFields,
indexPrivilege,
isReadOnlyRole,
isRoleReadOnly,
} = this.props;
if (!allowFieldLevelSecurity) {
@ -161,7 +161,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
return (
<>
<EuiFlexGroup direction="column">
{!isReadOnlyRole && (
{!isRoleReadOnly && (
<EuiFlexItem>
{
<EuiSwitch
@ -193,7 +193,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
fullWidth={true}
className="indexPrivilegeForm__grantedFieldsRow"
helpText={
!isReadOnlyRole && grant.length === 0 ? (
!isRoleReadOnly && grant.length === 0 ? (
<FormattedMessage
id="xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText"
defaultMessage="If no fields are granted, then users assigned to this role will not be able to see any data for this index."
@ -210,7 +210,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
selectedOptions={grant.map(toOption)}
onCreateOption={this.onCreateGrantedField}
onChange={this.onGrantedFieldsChange}
isDisabled={this.props.isReadOnlyRole}
isDisabled={this.props.isRoleReadOnly}
/>
</Fragment>
</EuiFormRow>
@ -233,7 +233,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
selectedOptions={except.map(toOption)}
onCreateOption={this.onCreateDeniedField}
onChange={this.onDeniedFieldsChange}
isDisabled={isReadOnlyRole}
isDisabled={isRoleReadOnly}
/>
</Fragment>
</EuiFormRow>
@ -248,7 +248,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
};
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<Props, State> {
return (
<EuiFlexGroup direction="column">
{!this.props.isReadOnlyRole && (
{!this.props.isRoleReadOnly && (
<EuiFlexItem>
{
<EuiSwitch
@ -270,7 +270,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
compressed={true}
checked={this.state.queryExpanded}
onChange={this.toggleDocumentQuery}
disabled={isReadOnlyRole}
disabled={isRoleReadOnly}
/>
}
</EuiFlexItem>
@ -292,7 +292,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
fullWidth={true}
value={indexPrivilege.query}
onChange={this.onQueryChange}
readOnly={this.props.isReadOnlyRole}
readOnly={this.props.isRoleReadOnly}
/>
</EuiFormRow>
</EuiFlexItem>

View file

@ -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<Props, State> {
// 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<Props, State> {
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;
}

View file

@ -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<Props, State> {
return (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>{addPrivilegeButton}</EuiFlexItem>
{hasPrivilegesAssigned && !isReservedRole(this.props.role) && (
{hasPrivilegesAssigned && !isRoleReserved(this.props.role) && (
<EuiFlexItem grow={false}>{viewMatrixButton}</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -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 (
<EuiToolTip
data-test-subj="reservedRoleBadgeTooltip"

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiIcon } from '@elastic/eui';
import { EuiIcon, EuiBasicTable } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
@ -14,6 +14,8 @@ import { RolesGridPage } from './roles_grid_page';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { rolesAPIClientMock } from '../index.mock';
import { ReservedBadge, DisabledBadge } from '../../badges';
import { findTestSubject } from 'test_utils/find_test_subject';
const mock403 = () => ({ body: { statusCode: 403 } });
@ -76,8 +78,24 @@ describe('<RolesGridPage />', () => {
});
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(
<RolesGridPage
rolesAPIClient={apiClientMock}
notifications={coreMock.createStart().notifications}
/>
);
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('<RolesGridPage />', () => {
wrapper.find('EuiButtonIcon[data-test-subj="clone-role-action-disabled-role"]')
).toHaveLength(1);
});
it('hides reserved roles when instructed to', async () => {
const wrapper = mountWithIntl(
<RolesGridPage
rolesAPIClient={apiClientMock}
notifications={coreMock.createStart().notifications}
/>
);
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: {} }],
},
]);
});
});

View file

@ -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<Props, State> {
super(props);
this.state = {
roles: [],
visibleRoles: [],
selection: [],
filter: '',
showDeleteConfirmation: false,
permissionDenied: false,
includeReservedRoles: true,
};
}
@ -125,16 +140,22 @@ export class RolesGridPage extends Component<Props, State> {
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<string, any>) => {
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<Props, State> {
};
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<Props, State> {
<EuiLink data-test-subj="roleRowName" href={getRoleManagementHref('edit', name)}>
{name}
</EuiLink>
{!isRoleEnabled(record) && (
<FormattedMessage
id="xpack.security.management.roles.disabledTooltip"
defaultMessage=" (disabled)"
/>
)}
</EuiText>
);
},
},
{
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 ? (
<span title={label}>
<EuiIcon aria-label={label} data-test-subj="reservedRole" type="check" />
</span>
) : 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<Props, State> {
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<Props, State> {
},
},
{
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<Props, State> {
] as Array<EuiBasicTableColumn<Role>>;
};
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))
);
});
};
return filter
? roles.filter(({ name }) => {
const normalized = `${name}`.toLowerCase();
const normalizedQuery = filter.toLowerCase();
return normalized.indexOf(normalizedQuery) !== -1;
})
: roles;
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);
const badges = [];
if (!enabled) {
badges.push(<DisabledBadge data-test-subj="roleDisabled" />);
}
if (reserved) {
badges.push(
<ReservedBadge
data-test-subj="roleReserved"
tooltipContent={
<FormattedMessage
id="xpack.security.management.roles.reservedRoleBadgeTooltip"
defaultMessage="Reserved roles are built-in and cannot be edited or removed."
/>
}
/>
);
}
if (deprecated) {
badges.push(
<DeprecatedBadge
data-test-subj="roleDeprecated"
tooltipContent={getExtendedRoleDeprecationNotice(role)}
/>
);
}
return (
<EuiFlexGroup gutterSize="s">
{badges.map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
{badge}
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
private handleDelete = () => {
@ -283,7 +330,14 @@ export class RolesGridPage extends Component<Props, State> {
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<Props, State> {
</EuiButton>
);
}
private renderToolsRight() {
return (
<EuiSwitch
data-test-subj="showReservedRolesSwitch"
label={
<FormattedMessage
id="xpack.security.management.roles.showReservedRolesLabel"
defaultMessage="Show reserved roles"
/>
}
checked={this.state.includeReservedRoles}
onChange={this.onIncludeReservedRolesChange}
/>
);
}
private onCancelDelete = () => {
this.setState({ showDeleteConfirmation: false });
};

View file

@ -40,7 +40,7 @@ describe('<ChangePasswordForm>', () => {
<ChangePasswordForm
user={user}
isUserChangingOwnPassword={true}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
/>
);
@ -68,7 +68,7 @@ describe('<ChangePasswordForm>', () => {
user={user}
isUserChangingOwnPassword={true}
onChangePassword={callback}
apiClient={apiClientMock}
userAPIClient={apiClientMock}
notifications={coreMock.createStart().notifications}
/>
);
@ -107,7 +107,7 @@ describe('<ChangePasswordForm>', () => {
<ChangePasswordForm
user={user}
isUserChangingOwnPassword={false}
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
/>
);

View file

@ -23,7 +23,7 @@ interface Props {
user: User;
isUserChangingOwnPassword: boolean;
onChangePassword?: () => void;
apiClient: PublicMethodsOf<UserAPIClient>;
userAPIClient: PublicMethodsOf<UserAPIClient>;
notifications: NotificationsStart;
}
@ -279,7 +279,7 @@ export class ChangePasswordForm extends Component<Props, State> {
private performPasswordChange = async () => {
try {
await this.props.apiClient.changePassword(
await this.props.userAPIClient.changePassword(
this.props.user.username,
this.state.newPassword,
this.state.currentPassword

View file

@ -15,7 +15,7 @@ describe('ConfirmDeleteUsers', () => {
it('renders a warning for a single user', () => {
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
usersToDelete={['foo']}
onCancel={jest.fn()}
@ -28,7 +28,7 @@ describe('ConfirmDeleteUsers', () => {
it('renders a warning for a multiple users', () => {
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
usersToDelete={['foo', 'bar', 'baz']}
onCancel={jest.fn()}
@ -42,7 +42,7 @@ describe('ConfirmDeleteUsers', () => {
const onCancel = jest.fn();
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
apiClient={userAPIClientMock.create()}
userAPIClient={userAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
usersToDelete={['foo']}
onCancel={onCancel}
@ -63,7 +63,7 @@ describe('ConfirmDeleteUsers', () => {
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
usersToDelete={['foo', 'bar']}
apiClient={apiClientMock}
userAPIClient={apiClientMock}
notifications={coreMock.createStart().notifications}
onCancel={onCancel}
/>
@ -90,7 +90,7 @@ describe('ConfirmDeleteUsers', () => {
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
usersToDelete={['foo', 'bar']}
apiClient={apiClientMock}
userAPIClient={apiClientMock}
notifications={coreMock.createStart().notifications}
onCancel={onCancel}
/>

View file

@ -13,7 +13,7 @@ import { UserAPIClient } from '../..';
interface Props {
usersToDelete: string[];
apiClient: PublicMethodsOf<UserAPIClient>;
userAPIClient: PublicMethodsOf<UserAPIClient>;
notifications: NotificationsStart;
onCancel: () => void;
callback?: (usersToDelete: string[], errors: string[]) => void;
@ -77,11 +77,11 @@ export class ConfirmDeleteUsers extends Component<Props, unknown> {
}
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',

View file

@ -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<any, any>) {
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(
<EditUserPage
username={'reserved_user'}
apiClient={apiClient}
username={user.username}
userAPIClient={apiClient}
rolesAPIClient={rolesAPIClient}
authc={securitySetup.authc}
notifications={coreMock.createStart().notifications}
@ -104,12 +118,13 @@ describe('EditUserPage', () => {
});
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(
<EditUserPage
username={''}
apiClient={apiClient}
username={user.username}
userAPIClient={apiClient}
rolesAPIClient={rolesAPIClient}
authc={securitySetup.authc}
notifications={coreMock.createStart().notifications}
@ -125,12 +140,13 @@ describe('EditUserPage', () => {
});
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(
<EditUserPage
username={'existing_user'}
apiClient={apiClient}
username={user.username}
userAPIClient={apiClient}
rolesAPIClient={rolesAPIClient}
authc={securitySetup.authc}
notifications={coreMock.createStart().notifications}
@ -142,8 +158,32 @@ describe('EditUserPage', () => {
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(
<EditUserPage
username={user.username}
userAPIClient={apiClient}
rolesAPIClient={rolesAPIClient}
authc={securitySetup.authc}
notifications={coreMock.createStart().notifications}
/>
);
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<any, any>) {

View file

@ -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>;
userAPIClient: PublicMethodsOf<UserAPIClient>;
rolesAPIClient: PublicMethodsOf<RolesAPIClient>;
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<Props, State> {
}
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<Props, State> {
currentUser,
user,
roles,
selectedRoles: user.roles.map(role => ({ label: role })) || [],
selectedRoles: user.roles || [],
});
}
@ -160,18 +160,16 @@ export class EditUserPage extends Component<Props, State> {
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<Props, State> {
user={this.state.user}
isUserChangingOwnPassword={userIsLoggedInUser}
onChangePassword={this.toggleChangePasswordForm}
apiClient={this.props.apiClient}
userAPIClient={this.props.userAPIClient}
notifications={this.props.notifications}
/>
</Fragment>
@ -346,7 +344,7 @@ export class EditUserPage extends Component<Props, State> {
});
};
private onRolesChange = (selectedRoles: Array<{ label: string }>) => {
private onRolesChange = (selectedRoles: string[]) => {
this.setState({
selectedRoles,
});
@ -365,8 +363,8 @@ export class EditUserPage extends Component<Props, State> {
public render() {
const {
user,
roles,
selectedRoles,
roles,
showChangePasswordForm,
isNewUser,
showDeleteConfirmation,
@ -380,6 +378,22 @@ export class EditUserPage extends Component<Props, State> {
return null;
}
const hasAnyDeprecatedRolesAssigned = selectedRoles.some(selected => {
const role = roles.find(r => r.name === selected);
return role && isRoleDeprecated(role);
});
const roleHelpText = hasAnyDeprecatedRolesAssigned ? (
<span data-test-subj="hasDeprecatedRolesAssignedHelpText">
<FormattedMessage
id="xpack.security.management.users.editUser.deprecatedRolesAssignedWarning"
defaultMessage="This user is assigned a deprecated role. Please migrate to a supported role."
/>
</span>
) : (
undefined
);
return (
<div className="secUsersEditPage">
<EuiPageContent className="secUsersEditPage__content">
@ -426,7 +440,7 @@ export class EditUserPage extends Component<Props, State> {
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<Props, State> {
'xpack.security.management.users.editUser.rolesFormRowLabel',
{ defaultMessage: 'Roles' }
)}
helpText={roleHelpText}
>
<EuiComboBox
data-test-subj="userFormRolesDropdown"
placeholder={i18n.translate(
'xpack.security.management.users.editUser.addRolesPlaceholder',
{ defaultMessage: 'Add roles' }
)}
<RoleComboBox
availableRoles={roles}
selectedRoleNames={selectedRoles}
onChange={this.onRolesChange}
isDisabled={reserved}
options={roles.map(role => {
return { 'data-test-subj': `roleOption-${role.name}`, label: role.name };
})}
selectedOptions={selectedRoles}
/>
</EuiFormRow>

View file

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

View file

@ -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(
<UsersGridPage
apiClient={apiClientMock}
userAPIClient={apiClientMock}
rolesAPIClient={rolesAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
/>
);
@ -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(
<UsersGridPage apiClient={apiClient} notifications={coreMock.createStart().notifications} />
<UsersGridPage
userAPIClient={apiClient}
rolesAPIClient={rolesAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
/>
);
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<User[]>([
{
username: 'foo',
email: 'foo@bar.net',
full_name: 'foo bar',
roles: ['kibana_user'],
enabled: false,
},
]);
});
const wrapper = mountWithIntl(
<UsersGridPage
userAPIClient={apiClientMock}
rolesAPIClient={rolesAPIClientMock.create()}
notifications={coreMock.createStart().notifications}
/>
);
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<User[]>([
{
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(
<UsersGridPage
userAPIClient={apiClientMock}
rolesAPIClient={roleAPIClientMock}
notifications={coreMock.createStart().notifications}
/>
);
await waitForRender(wrapper);
const deprecationTooltip = wrapper.find('[data-test-subj="roleDeprecationTooltip"]').props();
expect(deprecationTooltip).toMatchInlineSnapshot(`
Object {
"children": <div>
kibana_user
<EuiIcon
className="eui-alignTop"
color="warning"
size="s"
type="alert"
/>
</div>,
"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<User[]>([
{
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(
<UsersGridPage
userAPIClient={apiClientMock}
rolesAPIClient={roleAPIClientMock}
notifications={coreMock.createStart().notifications}
/>
);
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<any, any>) {
await Promise.resolve();
await Promise.resolve();
await nextTick();
wrapper.update();
}

View file

@ -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>;
userAPIClient: PublicMethodsOf<UserAPIClient>;
rolesAPIClient: PublicMethodsOf<RolesAPIClient>;
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<Props, State> {
@ -44,19 +54,22 @@ export class UsersGridPage extends Component<Props, State> {
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 (
<EuiFlexGroup gutterSize="none">
@ -86,17 +99,6 @@ export class UsersGridPage extends Component<Props, State> {
}
const path = '#/management/security/';
const columns: Array<EuiBasicTableColumn<User>> = [
{
field: 'full_name',
name: i18n.translate('xpack.security.management.users.fullNameColumnName', {
defaultMessage: 'Full Name',
}),
sortable: true,
truncateText: true,
render: (fullName: string) => {
return <div data-test-subj="userRowFullName">{fullName}</div>;
},
},
{
field: 'username',
name: i18n.translate('xpack.security.management.users.userNameColumnName', {
@ -110,6 +112,18 @@ export class UsersGridPage extends Component<Props, State> {
</EuiLink>
),
},
{
field: 'full_name',
name: i18n.translate('xpack.security.management.users.fullNameColumnName', {
defaultMessage: 'Full Name',
}),
sortable: true,
truncateText: true,
render: (fullName: string) => {
return <div data-test-subj="userRowFullName">{fullName}</div>;
},
},
{
field: 'email',
name: i18n.translate('xpack.security.management.users.emailAddressColumnName', {
@ -126,34 +140,27 @@ export class UsersGridPage extends Component<Props, State> {
name: i18n.translate('xpack.security.management.users.rolesColumnName', {
defaultMessage: 'Roles',
}),
width: '30%',
render: (rolenames: string[]) => {
const roleLinks = rolenames.map((rolename, index) => {
return (
<Fragment key={rolename}>
<EuiLink href={`${path}roles/edit/${rolename}`}>{rolename}</EuiLink>
{index === rolenames.length - 1 ? null : ', '}
</Fragment>
);
const roleDefinition = roles?.find(role => role.name === rolename) ?? rolename;
return <RoleTableDisplay role={roleDefinition} key={rolename} />;
});
return <div data-test-subj="userRowRoles">{roleLinks}</div>;
},
},
{
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 ? (
<EuiIcon aria-label="Reserved user" data-test-subj="reservedUser" type="check" />
) : null,
render: (metadata: User['metadata'], record: User) => this.getUserStatusBadges(record),
},
];
const pagination = {
@ -170,18 +177,24 @@ export class UsersGridPage extends Component<Props, State> {
};
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<Props, State> {
'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 (
<div className="secUsersListingPage">
<EuiPageContent className="secUsersListingPage__content">
@ -226,7 +233,7 @@ export class UsersGridPage extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
});
};
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<Props, State> {
);
}
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 (
<EuiSwitch
data-test-subj="showReservedUsersSwitch"
label={
<FormattedMessage
id="xpack.security.management.users.showReservedUsersLabel"
defaultMessage="Show reserved users"
/>
}
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(<DisabledBadge data-test-subj="userDisabled" />);
}
if (reserved) {
badges.push(
<ReservedBadge
data-test-subj="userReserved"
tooltipContent={
<FormattedMessage
id="xpack.security.management.users.reservedUserBadgeTooltip"
defaultMessage="Reserved users are built-in and cannot be edited or removed."
/>
}
/>
);
}
return (
<EuiFlexGroup gutterSize="s">
{badges.map((badge, index) => (
<EuiFlexItem key={index} grow={false}>
{badge}
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
private onCancelDelete = () => {
this.setState({ showDeleteConfirmation: false });
};

View file

@ -58,7 +58,7 @@ describe('usersManagementApp', () => {
expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Users' }]);
expect(container).toMatchInlineSnapshot(`
<div>
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":{}}}}
</div>
`);
@ -80,7 +80,7 @@ describe('usersManagementApp', () => {
]);
expect(container).toMatchInlineSnapshot(`
<div>
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":{}}}
</div>
`);
@ -103,7 +103,7 @@ describe('usersManagementApp', () => {
]);
expect(container).toMatchInlineSnapshot(`
<div>
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"}
</div>
`);

View file

@ -39,9 +39,16 @@ export const usersManagementApp = Object.freeze({
];
const userAPIClient = new UserAPIClient(http);
const rolesAPIClient = new RolesAPIClient(http);
const UsersGridPageWithBreadcrumbs = () => {
setBreadcrumbs(usersBreadcrumbs);
return <UsersGridPage notifications={notifications} apiClient={userAPIClient} />;
return (
<UsersGridPage
notifications={notifications}
userAPIClient={userAPIClient}
rolesAPIClient={rolesAPIClient}
/>
);
};
const EditUserPageWithBreadcrumbs = () => {
@ -61,7 +68,7 @@ export const usersManagementApp = Object.freeze({
return (
<EditUserPage
authc={authc}
apiClient={userAPIClient}
userAPIClient={userAPIClient}
rolesAPIClient={new RolesAPIClient(http)}
notifications={notifications}
username={username}

View file

@ -128,7 +128,7 @@ export class SecurityPlugin
<AccountManagementPage
authc={this.authc}
notifications={core.notifications}
apiClient={new UserAPIClient(core.http)}
userAPIClient={new UserAPIClient(core.http)}
/>
</core.i18n.Context>
),

View file

@ -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": "ユーザー",

View file

@ -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": "用户",

View file

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

View file

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

View file

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