diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts index cda3ab213fb87..e763264a041de 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts @@ -6,5 +6,5 @@ */ export * from '../../../common/types/app_search'; -export { Role, RoleTypes, AbilityTypes } from './utils/role'; +export { Role, RoleTypes, AbilityTypes, ASRoleMapping } from './utils/role'; export { Engine } from './components/engine/types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts index 3c75d34e6cdc9..6e0a2f8e2adf2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { RoleMapping } from '../../../shared/types'; +import { Engine } from '../../components/engine/types'; import { Account } from '../../types'; export type RoleTypes = 'owner' | 'admin' | 'dev' | 'editor' | 'analyst'; @@ -103,3 +105,11 @@ export const getRoleAbilities = (role: Account['role']): Role => { return Object.assign(myRole, topLevelProps, abilities); }; + +export interface ASRoleMapping extends RoleMapping { + accessAllEngines: boolean; + engines: Engine[]; + toolTip?: { + content: string; + }; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts new file mode 100644 index 0000000000000..6e9c867b15679 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const asRoleMapping = { + id: null, + attributeName: 'role', + attributeValue: ['superuser'], + authProvider: ['*'], + roleType: 'owner', + rules: { + role: 'superuser', + }, + accessAllEngines: true, + engines: [], + toolTip: { + content: 'Elasticsearch superusers will always be able to log in as the owner', + }, +}; + +export const wsRoleMapping = { + id: '602d4ba85foobarbaz123', + attributeName: 'username', + attributeValue: 'user', + authProvider: ['*', 'other_auth'], + roleType: 'admin', + rules: { + username: 'user', + }, + allGroups: true, + groups: [ + { + id: '602c3b475foobarbaz123', + name: 'Default', + createdAt: '2021-02-16T21:38:15Z', + updatedAt: '2021-02-16T21:40:32Z', + contentSources: [ + { + id: '602c3bcf5foobarbaz123', + name: 'National Parks', + serviceType: 'custom', + }, + ], + users: [ + { + id: '602c3b485foobarbaz123', + name: 'you_know_for_search', + email: 'foo@example.com', + initials: 'E', + pictureUrl: null, + color: '#ffcc13', + }, + { + id: '602c3bf85foobarbaz123', + name: 'elastic', + email: null, + initials: 'E', + pictureUrl: null, + color: '#7968ff', + }, + ], + usersCount: 2, + }, + ], +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx new file mode 100644 index 0000000000000..a02f6c43225c0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonTo } from '../react_router_helpers'; + +import { AddRoleMappingButton } from './add_role_mapping_button'; + +describe('AddRoleMappingButton', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButtonTo)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx new file mode 100644 index 0000000000000..0ae9f16ea2f9b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButtonTo } from '../react_router_helpers'; + +import { ADD_ROLE_MAPPING_BUTTON } from './constants'; + +interface Props { + path: string; +} + +export const AddRoleMappingButton: React.FC = ({ path }) => ( + + {ADD_ROLE_MAPPING_BUTTON} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx new file mode 100644 index 0000000000000..bc31732527b0e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiComboBox, EuiFieldText } from '@elastic/eui'; + +import { AttributeSelector, AttributeName } from './attribute_selector'; +import { ANY_AUTH_PROVIDER, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; + +const handleAttributeSelectorChange = jest.fn(); +const handleAttributeValueChange = jest.fn(); +const handleAuthProviderChange = jest.fn(); + +const baseProps = { + attributeName: 'username' as AttributeName, + attributeValue: 'Something', + attributes: ['a', 'b', 'c'], + availableAuthProviders: ['ees_saml', 'kbn_saml'], + selectedAuthProviders: ['ees_saml'], + elasticsearchRoles: ['whatever'], + multipleAuthProvidersConfig: true, + disabled: false, + handleAttributeSelectorChange, + handleAttributeValueChange, + handleAuthProviderChange, +}; + +describe('AttributeSelector', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="AttributeSelector"]').exists()).toBe(true); + }); + + it('renders disabled panel with className', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="AttributeSelector"]').prop('className')).toEqual( + 'euiPanel--disabled' + ); + }); + + describe('Auth Providers', () => { + const findAuthProvidersSelect = (wrapper: ShallowWrapper) => + wrapper.find('[data-test-subj="AuthProviderSelect"]'); + + it('will not render if "availableAuthProviders" prop has not been provided', () => { + const wrapper = shallow( + + ); + + expect(findAuthProvidersSelect(wrapper)).toHaveLength(0); + }); + + it('handles fallback props', () => { + const wrapper = shallow( + + ); + + const select: ShallowWrapper = findAuthProvidersSelect(wrapper); + + expect(select.prop('selectedOptions')).toEqual([ + { + label: ANY_AUTH_PROVIDER_OPTION_LABEL, + value: ANY_AUTH_PROVIDER, + }, + ]); + }); + + it('renders a list of auth providers from the "availableAuthProviders" prop including an "Any" option', () => { + const wrapper = shallow( + + ); + const select = findAuthProvidersSelect(wrapper) as any; + + expect(select.props().options).toEqual([ + { + label: expect.any(String), + options: [{ label: ANY_AUTH_PROVIDER_OPTION_LABEL, value: '*' }], + }, + { + label: expect.any(String), + options: [ + { label: 'ees_saml', value: 'ees_saml' }, + { label: 'kbn_saml', value: 'kbn_saml' }, + ], + }, + ]); + }); + + it('the "selectedAuthProviders" prop should be used as the selected value', () => { + const wrapper = shallow( + + ); + const select = findAuthProvidersSelect(wrapper) as any; + + expect(select.props().selectedOptions).toEqual([{ label: 'kbn_saml', value: 'kbn_saml' }]); + }); + + it('should call the "handleAuthProviderChange" prop when a value is selected', () => { + const wrapper = shallow(); + const select = findAuthProvidersSelect(wrapper); + select.simulate('change', [{ label: 'kbn_saml', value: 'kbn_saml' }]); + + expect(handleAuthProviderChange).toHaveBeenCalledWith(['kbn_saml']); + }); + + it('should call the "handleAttributeSelectorChange" prop when a value is selected', () => { + const wrapper = shallow(); + const select = wrapper.find('[data-test-subj="ExternalAttributeSelect"]'); + const event = { target: { value: 'kbn_saml' } }; + select.simulate('change', event); + + expect(handleAttributeSelectorChange).toHaveBeenCalledWith( + 'kbn_saml', + baseProps.elasticsearchRoles[0] + ); + }); + + it('handles fallback when no "handleAuthProviderChange" provided', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiComboBox).prop('onChange')!([])).toEqual(undefined); + }); + + it('should call the "handleAttributeSelectorChange" prop when field text value is changed', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldText); + const event = { target: { value: 'kbn_saml' } }; + input.simulate('change', event); + + expect(handleAttributeSelectorChange).toHaveBeenCalledWith( + 'kbn_saml', + baseProps.elasticsearchRoles[0] + ); + }); + + it('should call the "handleAttributeSelectorChange" prop when attribute value is selected', () => { + const wrapper = shallow(); + const select = wrapper.find('[data-test-subj="ElasticsearchRoleSelect"]'); + const event = { target: { value: 'kbn_saml' } }; + select.simulate('change', event); + + expect(handleAttributeSelectorChange).toHaveBeenCalledWith( + 'kbn_saml', + baseProps.elasticsearchRoles[0] + ); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx new file mode 100644 index 0000000000000..60d660a2f6862 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { + ANY_AUTH_PROVIDER, + ANY_AUTH_PROVIDER_OPTION_LABEL, + AUTH_ANY_PROVIDER_LABEL, + AUTH_INDIVIDUAL_PROVIDER_LABEL, + ATTRIBUTE_SELECTOR_TITLE, + AUTH_PROVIDER_LABEL, + EXTERNAL_ATTRIBUTE_LABEL, + ATTRIBUTE_VALUE_LABEL, +} from './constants'; + +export type AttributeName = keyof AttributeExamples | 'role'; + +interface Props { + attributeName: AttributeName; + attributeValue?: string; + attributes: string[]; + selectedAuthProviders?: string[]; + availableAuthProviders?: string[]; + elasticsearchRoles: string[]; + disabled: boolean; + multipleAuthProvidersConfig: boolean; + handleAttributeSelectorChange(value: string, elasticsearchRole: string): void; + handleAttributeValueChange(value: string): void; + handleAuthProviderChange?(value: string[]): void; +} + +interface AttributeExamples { + username: string; + email: string; + metadata: string; +} + +interface ParentOption extends EuiComboBoxOptionOption { + label: string; + options: ChildOption[]; +} + +interface ChildOption extends EuiComboBoxOptionOption { + value: string; + label: string; +} + +const attributeValueExamples: AttributeExamples = { + username: 'elastic,*_system', + email: 'user@example.com,*@example.org', + metadata: '{"_reserved": true}', +}; + +const getAuthProviderOptions = (availableAuthProviders: string[]) => { + return [ + { + label: AUTH_ANY_PROVIDER_LABEL, + options: [{ value: ANY_AUTH_PROVIDER, label: ANY_AUTH_PROVIDER_OPTION_LABEL }], + }, + { + label: AUTH_INDIVIDUAL_PROVIDER_LABEL, + options: availableAuthProviders.map((authProvider) => ({ + value: authProvider, + label: authProvider, + })), + }, + ]; +}; + +const getSelectedOptions = (selectedAuthProviders: string[], availableAuthProviders: string[]) => { + const groupedOptions: ParentOption[] = getAuthProviderOptions(availableAuthProviders); + const childOptions: ChildOption[] = []; + const options = groupedOptions.reduce((acc, n) => [...acc, ...n.options], childOptions); + return options.filter((o) => o.value && selectedAuthProviders.includes(o.value)); +}; + +export const AttributeSelector: React.FC = ({ + attributeName, + attributeValue = '', + attributes, + availableAuthProviders, + selectedAuthProviders = [ANY_AUTH_PROVIDER], + elasticsearchRoles, + disabled, + multipleAuthProvidersConfig, + handleAttributeSelectorChange, + handleAttributeValueChange, + handleAuthProviderChange = () => null, +}) => { + return ( + + +

{ATTRIBUTE_SELECTOR_TITLE}

+
+ + {availableAuthProviders && multipleAuthProvidersConfig && ( + + + + { + handleAuthProviderChange(options.map((o) => (o as ChildOption).value)); + }} + fullWidth + isDisabled={disabled} + /> + + + + + )} + + + + ({ value: attribute, text: attribute }))} + onChange={(e) => { + handleAttributeSelectorChange(e.target.value, elasticsearchRoles[0]); + }} + fullWidth + disabled={disabled} + /> + + + + + {attributeName === 'role' ? ( + ({ + value: elasticsearchRole, + text: elasticsearchRole, + }))} + onChange={(e) => { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + ) : ( + { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + )} + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts new file mode 100644 index 0000000000000..1fbbc172dcf69 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ANY_AUTH_PROVIDER = '*'; + +export const ANY_AUTH_PROVIDER_OPTION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.anyDropDownOptionLabel', + { + defaultMessage: 'Any', + } +); + +export const ADD_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.addRoleMappingButtonLabel', + { + defaultMessage: 'Add mapping', + } +); + +export const AUTH_ANY_PROVIDER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.anyAuthProviderLabel', + { + defaultMessage: 'Any current or future Auth Provider', + } +); + +export const AUTH_INDIVIDUAL_PROVIDER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.individualAuthProviderLabel', + { + defaultMessage: 'Select individual auth providers', + } +); + +export const ATTRIBUTE_SELECTOR_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.attributeSelectorTitle', + { + defaultMessage: 'Attribute mapping', + } +); + +export const ROLE_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.roleLabel', { + defaultMessage: 'Role', +}); + +export const ALL_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.allLabel', { + defaultMessage: 'All', +}); + +export const AUTH_PROVIDER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.authProviderLabel', + { + defaultMessage: 'Auth provider', + } +); + +export const EXTERNAL_ATTRIBUTE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.externalAttributeLabel', + { + defaultMessage: 'External attribute', + } +); + +export const ATTRIBUTE_VALUE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.attributeValueLabel', + { + defaultMessage: 'Attribute value', + } +); + +export const DELETE_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle', + { + defaultMessage: 'Remove this role mapping', + } +); + +export const DELETE_ROLE_MAPPING_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingDescription', + { + defaultMessage: 'Please note that deleting a mapping is permanent and cannot be undone', + } +); + +export const DELETE_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton', + { + defaultMessage: 'Delete mapping', + } +); + +export const FILTER_ROLE_MAPPINGS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.filterRoleMappingsPlaceholder', + { + defaultMessage: 'Filter roles...', + } +); + +export const MANAGE_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.manageRoleMappingButtonLabel', + { + defaultMessage: 'Manage', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx new file mode 100644 index 0000000000000..c7556ee20e26a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton, EuiCallOut } from '@elastic/eui'; + +import { DeleteMappingCallout } from './delete_mapping_callout'; + +describe('DeleteMappingCallout', () => { + const handleDeleteMapping = jest.fn(); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiButton).prop('onClick')).toEqual(handleDeleteMapping); + }); + + it('handles button click', () => { + const wrapper = shallow(); + wrapper.find(EuiButton).simulate('click'); + + expect(handleDeleteMapping).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx new file mode 100644 index 0000000000000..cb3c27038c566 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButton, EuiCallOut } from '@elastic/eui'; + +import { + DELETE_ROLE_MAPPING_TITLE, + DELETE_ROLE_MAPPING_DESCRIPTION, + DELETE_ROLE_MAPPING_BUTTON, +} from './constants'; + +interface Props { + handleDeleteMapping(): void; +} + +export const DeleteMappingCallout: React.FC = ({ handleDeleteMapping }) => ( + +

{DELETE_ROLE_MAPPING_DESCRIPTION}

+ + {DELETE_ROLE_MAPPING_BUTTON} + +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts new file mode 100644 index 0000000000000..e6320dbb7feef --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AddRoleMappingButton } from './add_role_mapping_button'; +export { AttributeSelector } from './attribute_selector'; +export { DeleteMappingCallout } from './delete_mapping_callout'; +export { RoleMappingsTable } from './role_mappings_table'; +export { RoleSelector } from './role_selector'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx new file mode 100644 index 0000000000000..22498bbc50c21 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFieldSearch, EuiTableRow } from '@elastic/eui'; + +import { wsRoleMapping, asRoleMapping } from './__mocks__/roles'; + +import { ALL_LABEL, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; + +import { RoleMappingsTable } from './role_mappings_table'; + +describe('RoleMappingsTable', () => { + const getRoleMappingPath = jest.fn(); + const roleMappings = [ + { + ...wsRoleMapping, + accessItems: [ + { + name: 'foo', + }, + ], + }, + ]; + + const props = { + accessItemKey: 'groups' as 'groups' | 'engines', + accessHeader: 'access', + roleMappings, + addMappingButton: