[Roles] Improved handling for operator-defined role mappings (#208710)

## Summary

Improved handling for operator-defined role mappings:

- Detail page with operator-defined role mappings is now marked with
lock icon and tooltip
- Operator-defined role mappings have a read-only experience.

<img width="1256" alt="Screenshot 2025-01-29 at 11 45 27"
src="https://github.com/user-attachments/assets/911dd2cd-4fe8-4141-8d8a-ffde974884d7"
/>
<img width="1234" alt="Screenshot 2025-01-28 at 15 21 44"
src="https://github.com/user-attachments/assets/d9a03431-f8da-471e-8c94-f504aa00623d"
/>

### How to test
1. Create a `settings.json` file in
`$ES_HOME/config/operator/settings.json`, and define some role mappings
there. Here's an example that will create 2 operator mappings:
```json
{
  "metadata": {
    "version": "1",
    "compatibility": "8.4.0"
  },
  "state": {
    "role_mappings": {
      "operator_role_mapping_1": {
        "enabled": true,
        "roles": [
          "kibana_user"
        ],
        "metadata": {
          "from_file": true
        },
        "rules": {
          "field": {
            "username": "role-mapping-test-user"
          }
        }
      },
      "operator_role_mapping_2": {
        "enabled": true,
        "roles": [
          "fleet_user"
        ],
        "metadata": {
          "from_file": true
        },
        "rules": {
          "field": {
            "username": "role-mapping-test-user"
          }
        }
      }
    }
  }
}
```
2. Navigate to `Role Mappings` page and check the UI has a read only
view.
3. Navigate to `Role Mappings Details` page and check the UI has a read
only view.

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

__Closes: https://github.com/elastic/kibana/issues/194635__

### Release Notes
Improved handling for operator-defined role mappings

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Elena Shostak 2025-02-04 11:23:28 +01:00 committed by GitHub
parent 2fd10dbabe
commit 7adc337c5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 152 additions and 10 deletions

View file

@ -437,6 +437,72 @@ describe('EditRoleMappingPage', () => {
expect(rulePanels.at(0).props().readOnly).toBeTruthy();
});
it('renders a readonly view when role mapping has metadata._readonly=true', async () => {
const roleMappingsAPI = roleMappingsAPIClientMock.create();
const securityFeaturesAPI = securityFeaturesAPIClientMock.create();
roleMappingsAPI.saveRoleMapping.mockResolvedValue(null);
roleMappingsAPI.getRoleMapping.mockResolvedValue({
name: 'foo',
role_templates: [
{
template: { id: 'foo' },
},
],
enabled: true,
rules: {
all: [
{
field: {
username: '*',
},
},
{
all: [],
},
],
},
metadata: {
_read_only: true,
},
});
securityFeaturesAPI.checkFeatures.mockResolvedValue({
canReadSecurity: true,
hasCompatibleRealms: true,
canUseInlineScripts: true,
canUseStoredScripts: true,
});
const wrapper = renderView(roleMappingsAPI, securityFeaturesAPI, 'foo');
await nextTick();
wrapper.update();
// back button
const backButton = wrapper.find('button[data-test-subj="roleMappingFormReturnButton"]');
expect(backButton).toHaveLength(1);
// no save button
const saveButton = wrapper.find('button[data-test-subj="saveRoleMappingButton"]');
expect(saveButton).toHaveLength(0);
// no delete button
const deleteButton = wrapper.find('emptyButton[data-test-subj="deleteRoleMappingButton"]');
expect(deleteButton).toHaveLength(0);
// Info panel is read-only (view mode)
const infoPanels = wrapper.find('MappingInfoPanel[data-test-subj="roleMappingInfoPanel"]');
expect(infoPanels).toHaveLength(1);
expect(infoPanels.at(0).props().mode).toEqual('view');
// Rule panel is read-only
const rulePanels = wrapper.find('RuleEditorPanel[data-test-subj="roleMappingRulePanel"]');
expect(rulePanels).toHaveLength(1);
expect(rulePanels.at(0).props().readOnly).toBeTruthy();
// Lock icon is displayed
const lockIcon = wrapper.find('EuiToolTip[data-test-subj="readOnlyRoleMappingTooltip"]');
expect(lockIcon).toHaveLength(1);
});
it('renders a warning when empty any or all rules are present', async () => {
const roleMappingsAPI = roleMappingsAPIClientMock.create();
const securityFeaturesAPI = securityFeaturesAPIClientMock.create();

View file

@ -12,10 +12,12 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiIcon,
EuiLink,
EuiPageHeader,
EuiPageSection,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import React, { Component } from 'react';
@ -179,7 +181,7 @@ export class EditRoleMappingPage extends Component<Props, State> {
})
}
docLinks={this.props.docLinks}
readOnly={this.props.readOnly}
readOnly={this.isReadOnly()}
/>
<EuiSpacer />
{this.getFormWarnings()}
@ -191,16 +193,32 @@ export class EditRoleMappingPage extends Component<Props, State> {
}
private getInfoPanelMode = () => {
return this.props.readOnly ? 'view' : this.editingExistingRoleMapping() ? 'edit' : 'create';
return this.isReadOnly() ? 'view' : this.editingExistingRoleMapping() ? 'edit' : 'create';
};
private getFormTitle = () => {
if (this.props.readOnly) {
if (this.isReadOnly()) {
return (
<FormattedMessage
id="xpack.security.management.editRoleMapping.readOnlyRoleMappingTitle"
defaultMessage="Viewing role mapping"
/>
<>
<FormattedMessage
id="xpack.security.management.editRoleMapping.readOnlyRoleMappingTitle"
defaultMessage="Viewing role mapping"
/>
&nbsp;
{this.isReadOnlyRoleMapping() && (
<EuiToolTip
data-test-subj="readOnlyRoleMappingTooltip"
content={
<FormattedMessage
id="xpack.security.management.editRoleMapping.readOnlyRoleMappingBadge.readOnlyRoleMappingCanNotBeModifiedTooltip"
defaultMessage="Read only role mappings are built-in and cannot be removed or modified."
/>
}
>
<EuiIcon style={{ verticalAlign: 'super' }} type={'lock'} />
</EuiToolTip>
)}
</>
);
}
if (this.editingExistingRoleMapping()) {
@ -279,7 +297,7 @@ export class EditRoleMappingPage extends Component<Props, State> {
};
private getFormButtons = () => {
if (this.props.readOnly === true) {
if (this.isReadOnly() === true) {
return this.getReturnToRoleMappingListButton();
}
@ -338,7 +356,7 @@ export class EditRoleMappingPage extends Component<Props, State> {
};
private getDeleteButton = () => {
if (this.editingExistingRoleMapping() && !this.props.readOnly) {
if (this.editingExistingRoleMapping() && !this.isReadOnly()) {
return (
<EuiFlexItem grow={false}>
<DeleteProvider
@ -427,6 +445,10 @@ export class EditRoleMappingPage extends Component<Props, State> {
private cloningExistingRoleMapping = () =>
typeof this.props.name === 'string' && this.props.action === 'clone';
private isReadOnlyRoleMapping = () => this.state.roleMapping?.metadata?._read_only;
private isReadOnly = () => this.props.readOnly || this.isReadOnlyRoleMapping();
private async loadAppData() {
try {
const [features, roleMapping] = await Promise.all([

View file

@ -396,5 +396,46 @@ describe('RoleMappingsGridPage', () => {
const actionMenuButton = wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]');
expect(actionMenuButton).toHaveLength(0);
});
it('hides controls when role mapping is read only', async () => {
const roleMappingsAPI = roleMappingsAPIClientMock.create();
const securityFeaturesAPI = securityFeaturesAPIClientMock.create();
roleMappingsAPI.getRoleMappings.mockResolvedValue([
{
name: 'some-realm',
enabled: true,
roles: ['superuser'],
metadata: { _read_only: true },
rules: { field: { username: '*' } },
},
]);
securityFeaturesAPI.checkFeatures.mockResolvedValue({
canReadSecurity: true,
hasCompatibleRealms: true,
});
roleMappingsAPI.deleteRoleMappings.mockResolvedValue([
{
name: 'some-realm',
success: true,
},
]);
const wrapper = renderView(roleMappingsAPI, securityFeaturesAPI);
await nextTick();
wrapper.update();
// role mapping actions are hidden
const editButton = wrapper.find('[data-test-subj="editRoleMappingButton-some-realm"]');
expect(editButton).toHaveLength(0);
const cloneButton = wrapper.find('[data-test-subj="cloneRoleMappingButton-some-realm"]');
expect(cloneButton).toHaveLength(0);
const deleteButton = wrapper.find('[data-test-subj="deleteRoleMappingButton-some-realm"]');
expect(deleteButton).toHaveLength(0);
const actionMenuButton = wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]');
expect(actionMenuButton).toHaveLength(0);
});
});
});

View file

@ -194,6 +194,8 @@ export class RoleMappingsGridPage extends Component<Props, State> {
);
}
private isReadOnlyRoleMapping = (record: RoleMapping) => record.metadata?._read_only;
private renderTable = () => {
const { roleMappings, selectedItems, loadState } = this.state;
@ -283,7 +285,15 @@ export class RoleMappingsGridPage extends Component<Props, State> {
columns={this.getColumnConfig(deleteRoleMappingPrompt)}
search={search}
sorting={sorting}
selection={this.props.readOnly ? undefined : selection}
selection={
this.props.readOnly
? undefined
: {
selectable: (roleMapping: RoleMapping) =>
!this.isReadOnlyRoleMapping(roleMapping),
...selection,
}
}
pagination={pagination}
loading={loadState === 'loadingTable'}
message={message}
@ -386,6 +396,7 @@ export class RoleMappingsGridPage extends Component<Props, State> {
name: i18n.translate('xpack.security.management.roleMappings.actionCloneTooltip', {
defaultMessage: 'Clone',
}),
available: (roleMapping: RoleMapping) => !this.isReadOnlyRoleMapping(roleMapping),
description: (record: RoleMapping) =>
i18n.translate('xpack.security.management.roleMappings.actionCloneAriaLabel', {
defaultMessage: `Clone ''{name}''`,
@ -406,6 +417,7 @@ export class RoleMappingsGridPage extends Component<Props, State> {
name: i18n.translate('xpack.security.management.roleMappings.actionDeleteTooltip', {
defaultMessage: 'Delete',
}),
available: (roleMapping: RoleMapping) => !this.isReadOnlyRoleMapping(roleMapping),
description: (record: RoleMapping) =>
i18n.translate('xpack.security.management.roleMappings.actionDeleteAriaLabel', {
defaultMessage: `Delete ''{name}''`,
@ -422,6 +434,7 @@ export class RoleMappingsGridPage extends Component<Props, State> {
name: i18n.translate('xpack.security.management.roleMappings.actionEditTooltip', {
defaultMessage: 'Edit',
}),
available: (roleMapping: RoleMapping) => !this.isReadOnlyRoleMapping(roleMapping),
description: (record: RoleMapping) =>
i18n.translate('xpack.security.management.roleMappings.actionEditAriaLabel', {
defaultMessage: `Edit ''{name}''`,