[Roles] Support for remote_cluster field in ES role definition (#182377)

## Summary

Added support for `remote_cluster` field in ES role definition. The
change is needed for running queries with `ENRICH` keyword that are sent
over CCS.

- Updated GET and PUT APIs, documentation and corresponding data models.
- Added UI section to support the new API features.
- Extracted remote clusters ComboBox to a separate component
[RemoteClusterComboBox](https://github.com/elastic/kibana/pull/182377/files#diff-6b3189b6d802fd2196bcc445dc5c6021af70cf165fe3f8c4d4a5e6a4df651309R22)
to share it between the remote clusters and remote index privilege
views.



d3cf8b9c-e83d-4ace-ba2e-f8e028977f2d




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

### For maintainers

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

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

## Release note
Added support for `remote_cluster` field in ES role definition.
This commit is contained in:
elena-shostak 2024-05-08 10:52:35 +02:00 committed by GitHub
parent 1798e7b7ab
commit 25098d9a72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1694 additions and 130 deletions

View file

@ -26,7 +26,7 @@ To use the create or update role API, you must have the `manage_security` cluste
`elasticsearch`::
(Optional, object) {es} cluster and index privileges. Valid keys include
`cluster`, `indices`, `remote_indices`, and `run_as`. For more information, see
`cluster`, `indices`, `remote_indices`, `remote_cluster`, and `run_as`. For more information, see
{ref}/defining-roles.html[Defining roles].
`kibana`::
@ -210,6 +210,12 @@ $ curl -X PUT api/security/role/my_kibana_role
"names": [ "remote_index1", "remote_index2" ],
"privileges": [ "all" ]
}
],
"remote_cluster": [
{
"clusters": [ "remote_cluster1" ],
"privileges": [ "monitor_enrich" ]
}
]
},
"kibana": [

View file

@ -16,6 +16,7 @@ export type {
RoleIndexPrivilege,
RoleKibanaPrivilege,
RoleRemoteIndexPrivilege,
RoleRemoteClusterPrivilege,
FeaturesPrivileges,
} from './src/authorization';
export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './src/licensing';

View file

@ -11,4 +11,5 @@ export type {
RoleKibanaPrivilege,
RoleIndexPrivilege,
RoleRemoteIndexPrivilege,
RoleRemoteClusterPrivilege,
} from './role';

View file

@ -28,11 +28,17 @@ export interface RoleKibanaPrivilege {
_reserved?: string[];
}
export interface RoleRemoteClusterPrivilege {
clusters: string[];
privileges: string[];
}
export interface Role {
name: string;
description?: string;
elasticsearch: {
cluster: string[];
remote_cluster?: RoleRemoteClusterPrivilege[];
indices: RoleIndexPrivilege[];
remote_indices?: RoleRemoteIndexPrivilege[];
run_as: string[];

View file

@ -59,6 +59,11 @@ export interface SecurityLicenseFeatures {
*/
readonly allowRoleRemoteIndexPrivileges: boolean;
/**
* Indicates whether we allow users to define remote cluster privileges in roles.
*/
readonly allowRemoteClusterPrivileges: boolean;
/**
* Indicates whether we allow Role-based access control (RBAC).
*/

View file

@ -22,6 +22,19 @@ export const elasticsearchRoleSchema = schema.object({
*/
cluster: schema.maybe(schema.arrayOf(schema.string())),
/**
* An optional list of remote cluster privileges. These privileges define the remote cluster level actions that
* users with this role are able to execute
*/
remote_cluster: schema.maybe(
schema.arrayOf(
schema.object({
privileges: schema.arrayOf(schema.string(), { minSize: 1 }),
clusters: schema.arrayOf(schema.string(), { minSize: 1 }),
})
)
),
/**
* An optional list of indices permissions entries.
*/

View file

@ -31,6 +31,7 @@ export type {
RoleIndexPrivilege,
RoleKibanaPrivilege,
RoleRemoteIndexPrivilege,
RoleRemoteClusterPrivilege,
FeaturesPrivileges,
LoginLayout,
SecurityLicenseFeatures,

View file

@ -26,6 +26,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
layout: 'error-es-unavailable',
allowRbac: false,
allowSubFeaturePrivileges: false,
@ -50,6 +51,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
layout: 'error-xpack-unavailable',
allowRbac: false,
allowSubFeaturePrivileges: false,
@ -78,6 +80,7 @@ describe('license features', function () {
"allowAuditLogging": false,
"allowLogin": false,
"allowRbac": false,
"allowRemoteClusterPrivileges": false,
"allowRoleDocumentLevelSecurity": false,
"allowRoleFieldLevelSecurity": false,
"allowRoleRemoteIndexPrivileges": false,
@ -101,6 +104,7 @@ describe('license features', function () {
"allowAuditLogging": true,
"allowLogin": true,
"allowRbac": true,
"allowRemoteClusterPrivileges": true,
"allowRoleDocumentLevelSecurity": true,
"allowRoleFieldLevelSecurity": true,
"allowRoleRemoteIndexPrivileges": true,
@ -137,6 +141,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
allowRbac: true,
allowSubFeaturePrivileges: false,
allowAuditLogging: false,
@ -164,6 +169,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
allowRbac: false,
allowSubFeaturePrivileges: false,
allowAuditLogging: false,
@ -190,6 +196,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
allowRbac: true,
allowSubFeaturePrivileges: false,
allowAuditLogging: false,
@ -216,6 +223,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
allowRbac: true,
allowSubFeaturePrivileges: true,
allowAuditLogging: true,
@ -242,6 +250,7 @@ describe('license features', function () {
allowRoleDocumentLevelSecurity: true,
allowRoleFieldLevelSecurity: true,
allowRoleRemoteIndexPrivileges: true,
allowRemoteClusterPrivileges: true,
allowRbac: true,
allowSubFeaturePrivileges: true,
allowAuditLogging: true,

View file

@ -77,6 +77,7 @@ export class SecurityLicenseService {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
allowRbac: false,
allowSubFeaturePrivileges: false,
allowUserProfileCollaboration: false,
@ -98,6 +99,7 @@ export class SecurityLicenseService {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
allowRbac: false,
allowSubFeaturePrivileges: false,
allowUserProfileCollaboration: false,
@ -119,6 +121,7 @@ export class SecurityLicenseService {
allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter,
allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter,
allowRoleRemoteIndexPrivileges: isLicensePlatinumOrBetter,
allowRemoteClusterPrivileges: isLicensePlatinumOrBetter,
allowRbac: true,
allowUserProfileCollaboration: isLicenseStandardOrBetter,
};

View file

@ -8,4 +8,5 @@
export interface BuiltinESPrivileges {
cluster: string[];
index: string[];
remote_cluster: string[];
}

View file

@ -15,6 +15,7 @@ export interface CheckRoleMappingFeaturesResponse {
canUseStoredScripts: boolean;
hasCompatibleRealms: boolean;
canUseRemoteIndices: boolean;
canUseRemoteClusters: boolean;
}
type DeleteRoleMappingsResponse = Array<{

View file

@ -211,7 +211,7 @@ function useRole(
? rolesAPIClient.getRole(roleName)
: Promise.resolve({
name: '',
elasticsearch: { cluster: [], indices: [], run_as: [] },
elasticsearch: { cluster: [], indices: [], run_as: [], remote_cluster: [] },
kibana: [],
_unrecognized_applications: [],
} as Role);
@ -529,6 +529,9 @@ export const EditRolePage: FunctionComponent<Props> = ({
canUseRemoteIndices={
buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteIndices
}
canUseRemoteClusters={
buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteClusters
}
isDarkMode={isDarkMode}
buildFlavor={buildFlavor}
/>

View file

@ -59,6 +59,7 @@ exports[`it renders correctly in serverless mode 1`] = `
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": Array [],
"run_as": Array [],
},
"kibana": Array [],
@ -141,6 +142,7 @@ exports[`it renders correctly in serverless mode 1`] = `
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": Array [],
"run_as": Array [],
},
"kibana": Array [],
@ -215,6 +217,7 @@ exports[`it renders without crashing 1`] = `
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": Array [],
"run_as": Array [],
},
"kibana": Array [],
@ -346,6 +349,7 @@ exports[`it renders without crashing 1`] = `
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": Array [],
"run_as": Array [],
},
"kibana": Array [],

View file

@ -36,6 +36,7 @@ exports[`it renders without crashing 1`] = `
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": Array [],
"run_as": Array [],
},
"kibana": Array [],

View file

@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`it renders without crashing 1`] = `
<RemoteClusterPrivileges
availableRemoteClusterPrivileges={
Array [
"monitor_enrich",
]
}
editable={true}
license={
Object {
"features$": Observable {
"_subscribe": [Function],
},
"getFeatures": [MockFunction],
"getUnavailableReason": [MockFunction],
"hasAtLeast": [MockFunction],
"isEnabled": [MockFunction],
"isLicenseAvailable": [MockFunction],
}
}
onChange={[MockFunction]}
role={
Object {
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": Array [
Object {
"clusters": Array [
"cluster1",
"cluster2",
],
"privileges": Array [
"monitor_enrich",
],
},
],
"run_as": Array [],
},
"kibana": Array [],
"name": "",
}
}
validator={
RoleValidator {
"shouldValidate": undefined,
}
}
/>
`;

View file

@ -0,0 +1,116 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`it renders without crashing 1`] = `
<Fragment>
<EuiSpacer
size="m"
/>
<EuiFlexGroup
alignItems="center"
className="remote-cluster-privilege-form"
responsive={false}
>
<EuiFlexItem>
<EuiPanel
color="subdued"
>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Remote clusters"
id="xpack.security.management.editRole.remoteClusterPrivilegeForm.clustersFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<RemoteClusterComboBox
data-test-subj="remoteClusterClustersInput0"
fullWidth={true}
isDisabled={false}
onChange={[Function]}
onCreateOption={[Function]}
placeholder="Add a remote cluster…"
remoteClusters={Array []}
selectedOptions={
Array [
Object {
"label": "cluster1",
},
]
}
type="remote_cluster"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Privileges"
id="xpack.security.management.editRole.remoteClusterPrivilegeForm.privilegesFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiComboBox
async={false}
compressed={false}
data-test-subj="remoteClusterPrivilegesInput0"
fullWidth={true}
isClearable={true}
isDisabled={false}
onChange={[Function]}
optionMatcher={[Function]}
options={
Array [
Object {
"label": "monitor_enrich",
},
]
}
placeholder="Add an action…"
selectedOptions={
Array [
Object {
"label": "monitor_enrich",
},
]
}
singleSelection={false}
sortMatchesBy="none"
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButtonIcon
aria-label="Delete remote cluster privilege"
color="danger"
data-test-subj="deleteRemoteClusterPrivilegesButton0"
iconType="trash"
onClick={[MockFunction]}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
`;

View file

@ -0,0 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`it renders without crashing 1`] = `
<EuiComboBox
async={false}
compressed={false}
dat-test-subj="remoteClusterClustersInput0"
fullWidth={false}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
isClearable={true}
optionMatcher={[Function]}
options={
Array [
Object {
"label": "test2",
},
Object {
"label": "test3",
},
Object {
"isGroupLabelOption": true,
"label": "Incompatible clusters",
},
Object {
"append": <EuiIconTip
color="inherit"
content={
<FormattedMessage
defaultMessage="This cluster is configured with the certificate based security model and does not support remote cluster privileges. Connect this cluster with the API key based security model instead to use remote cluster privileges."
id="xpack.security.management.editRole.remoteClusterPrivilegeForm.remoteClusterSecurityModelWarning"
values={Object {}}
/>
}
type="warning"
/>,
"disabled": true,
"label": "test1",
},
]
}
selectedOptions={Array []}
singleSelection={false}
sortMatchesBy="none"
/>
`;

View file

@ -17,6 +17,7 @@ test('it renders without crashing', () => {
const role: Role = {
name: '',
elasticsearch: {
remote_cluster: [],
cluster: [],
indices: [],
run_as: [],
@ -39,6 +40,7 @@ test('it renders fields as disabled when not editable', () => {
name: '',
elasticsearch: {
cluster: [],
remote_cluster: [],
indices: [],
run_as: [],
},
@ -61,6 +63,7 @@ test('it allows for custom cluster privileges', () => {
name: '',
elasticsearch: {
cluster: ['existing-custom', 'monitor'],
remote_cluster: [],
indices: [],
run_as: [],
},

View file

@ -30,6 +30,7 @@ function getProps() {
name: '',
elasticsearch: {
cluster: [],
remote_cluster: [],
indices: [],
run_as: [],
},
@ -43,6 +44,7 @@ function getProps() {
builtinESPrivileges: {
cluster: ['all', 'manage', 'monitor'],
index: ['all', 'read', 'write', 'index'],
remote_cluster: [],
},
indicesAPIClient: indicesAPIClientMock.create(),
docLinks,
@ -75,13 +77,26 @@ test('it renders remote index privileges section when `canUseRemoteIndices` is e
expect(wrapper.find('IndexPrivileges[indexType="remote_indices"]')).toHaveLength(1);
});
test('it does not render remote cluster privileges section by default', () => {
const wrapper = shallowWithIntl(<ElasticsearchPrivileges {...getProps()} />);
expect(wrapper.find('RemoteClusterPrivileges')).toHaveLength(0);
});
test('it renders remote index privileges section when `canUseRemoteClusters` is enabled', () => {
const wrapper = shallowWithIntl(<ElasticsearchPrivileges {...getProps()} canUseRemoteClusters />);
expect(wrapper.find('RemoteClusterPrivileges')).toHaveLength(1);
});
test('it renders fields as disabled when not editable', () => {
const wrapper = shallowWithIntl(<ElasticsearchPrivileges {...getProps()} editable={false} />);
const wrapper = shallowWithIntl(
<ElasticsearchPrivileges {...getProps()} canUseRemoteClusters editable={false} />
);
expect(wrapper.find('EuiComboBox').prop('isDisabled')).toBe(true);
expect(wrapper.find('ClusterPrivileges').prop('editable')).toBe(false);
expect(
wrapper.find('IndexPrivileges').everyWhere((component) => component.prop('editable'))
).toBe(false);
expect(wrapper.find('RemoteClusterPrivileges').prop('editable')).toBe(false);
});
test('it renders correctly in serverless mode', () => {

View file

@ -25,6 +25,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
import { ClusterPrivileges } from './cluster_privileges';
import { IndexPrivileges } from './index_privileges';
import { RemoteClusterPrivileges } from './remote_cluster_privileges';
import type { BuiltinESPrivileges, Role, SecurityLicense } from '../../../../../../common';
import type { IndicesAPIClient } from '../../../indices_api_client';
import { CollapsiblePanel } from '../../collapsible_panel';
@ -43,6 +44,7 @@ interface Props {
indexPatterns: string[];
remoteClusters?: Cluster[];
canUseRemoteIndices?: boolean;
canUseRemoteClusters?: boolean;
isDarkMode?: boolean;
buildFlavor: BuildFlavor;
}
@ -69,6 +71,7 @@ export class ElasticsearchPrivileges extends Component<Props, {}> {
license,
builtinESPrivileges,
canUseRemoteIndices,
canUseRemoteClusters,
buildFlavor,
} = this.props;
@ -219,6 +222,40 @@ export class ElasticsearchPrivileges extends Component<Props, {}> {
/>
</>
)}
{buildFlavor === 'traditional' && canUseRemoteClusters && (
<>
<EuiSpacer />
<EuiSpacer />
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.security.management.editRole.elasticSearchPrivileges.remoteClusterPrivilegesTitle"
defaultMessage="Remote cluster privileges"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.security.management.editRole.elasticSearchPrivileges.controlAccessToRemoteClusterDataDescription"
defaultMessage="Control access to the data in remote clusters. "
/>
{this.learnMore(docLinks.links.security.clusterPrivileges)}
</p>
</EuiText>
<RemoteClusterPrivileges
remoteClusters={remoteClusters}
role={role}
validator={validator}
license={license}
onChange={onChange}
availableRemoteClusterPrivileges={builtinESPrivileges.remote_cluster ?? []}
editable={editable}
/>
</>
)}
</Fragment>
);
};

View file

@ -107,70 +107,6 @@ test('should not render clusters field for local indices', () => {
expect(wrapper.find('[data-test-subj="clustersInput0"]')).toHaveLength(0);
});
test('should render clusters field for remote indices', () => {
const wrapper = shallowWithIntl(
<IndexPrivilegeForm
indexType="remote_indices"
indexPrivilege={{
clusters: [],
names: [],
privileges: [],
query: '',
field_security: {
grant: [],
},
}}
remoteClusters={[
{
name: 'test1',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'certificate',
},
{
name: 'test2',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'api_key',
},
]}
formIndex={0}
indexPatterns={[]}
indicesAPIClient={indicesAPIClientMock.create()}
availableIndexPrivileges={['all', 'read', 'write', 'index']}
isRoleReadOnly={false}
allowDocumentLevelSecurity
allowFieldLevelSecurity
validator={new RoleValidator()}
onChange={jest.fn()}
onDelete={jest.fn()}
/>
);
const clustersInput = wrapper.find('[data-test-subj="clustersInput0"]');
expect(clustersInput).toHaveLength(1);
expect(clustersInput.prop('options')).toEqual([
{ label: 'test2' },
{ label: expect.anything(), isGroupLabelOption: true },
{
label: 'test1',
disabled: true,
append: expect.anything(),
},
]);
});
describe('delete button', () => {
const props = {
indexType: 'indices' as const,

View file

@ -12,7 +12,6 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiPanel,
EuiSpacer,
EuiSwitch,
@ -27,6 +26,7 @@ import type { monaco } from '@kbn/monaco';
import type { Cluster } from '@kbn/remote-clusters-plugin/public';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { RemoteClusterComboBox } from './remote_clusters_combo_box';
import type { RoleIndexPrivilege, RoleRemoteIndexPrivilege } from '../../../../../../common';
import type { IndicesAPIClient } from '../../../indices_api_client';
import type { RoleValidator } from '../../validate_role';
@ -125,45 +125,6 @@ export class IndexPrivilegeForm extends Component<Props, State> {
}
private getPrivilegeForm = () => {
const remoteClusterOptions: EuiComboBoxOptionOption[] = [];
if (this.props.remoteClusters) {
const incompatibleOptions: EuiComboBoxOptionOption[] = [];
this.props.remoteClusters.forEach((item, i) => {
const disabled = item.securityModel !== 'api_key';
if (!disabled) {
remoteClusterOptions.push({
label: item.name,
});
} else {
incompatibleOptions.push({
label: item.name,
disabled,
append: disabled ? (
<EuiIconTip
type="warning"
color="inherit"
content={
<FormattedMessage
id="xpack.security.management.editRole.indexPrivilegeForm.remoteIndicesSecurityModelWarning"
defaultMessage="This cluster is configured with the certificate based security model and does not support remote index privileges. Connect this cluster with the API key based security model instead to use remote index privileges."
/>
}
/>
) : undefined,
});
}
});
if (incompatibleOptions.length) {
remoteClusterOptions.push(
{
label: 'Incompatible clusters',
isGroupLabelOption: true,
},
...incompatibleOptions
);
}
}
return (
<>
<EuiFlexGroup>
@ -181,9 +142,8 @@ export class IndexPrivilegeForm extends Component<Props, State> {
this.props.indexPrivilege as RoleRemoteIndexPrivilege
)}
>
<EuiComboBox
<RemoteClusterComboBox
data-test-subj={`clustersInput${this.props.formIndex}`}
options={remoteClusterOptions}
selectedOptions={('clusters' in this.props.indexPrivilege &&
this.props.indexPrivilege.clusters
? this.props.indexPrivilege.clusters
@ -196,6 +156,8 @@ export class IndexPrivilegeForm extends Component<Props, State> {
'xpack.security.management.editRole.indexPrivilegeForm.clustersPlaceholder',
{ defaultMessage: 'Add a remote cluster…' }
)}
remoteClusters={this.props.remoteClusters ?? []}
type="remote_indexes"
fullWidth
/>
</EuiFormRow>

View file

@ -38,6 +38,7 @@ test('it renders without crashing', async () => {
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [],
indices: [],
run_as: [],
},
@ -75,6 +76,7 @@ test('it renders an IndexPrivilegeForm for each index privilege on the role', as
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [],
indices: [
{
names: ['foo*'],
@ -129,6 +131,7 @@ test('it renders an IndexPrivilegeForm for each remote index privilege on the ro
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [],
indices: [],
remote_indices: [
{
@ -183,6 +186,7 @@ test('it renders fields as disabled when not editable', async () => {
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [],
indices: [
{
names: ['foo*'],

View file

@ -0,0 +1,175 @@
/*
* 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 { coreMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { SecurityLicenseFeatures } from '@kbn/security-plugin-types-common';
import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers';
import '@kbn/code-editor-mock/jest_helper';
import { RemoteClusterPrivileges } from './remote_cluster_privileges';
import { RemoteClusterPrivilegesForm } from './remote_cluster_privileges_form';
import { licenseMock } from '../../../../../../common/licensing/index.mock';
import { RoleValidator } from '../../validate_role';
test('it renders without crashing', async () => {
const wrapper = shallowWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<RemoteClusterPrivileges
role={{
name: '',
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
],
indices: [],
run_as: [],
},
}}
onChange={jest.fn()}
editable
validator={new RoleValidator()}
availableRemoteClusterPrivileges={['monitor_enrich']}
license={licenseMock.create()}
/>
</KibanaContextProvider>
);
expect(wrapper.children()).toMatchSnapshot();
});
test('it renders an RemoteClusterPrivilegesForm for each remote cluster privilege on the role', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<RemoteClusterPrivileges
role={{
name: '',
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
{
clusters: ['cluster3', 'cluster4'],
privileges: ['monitor_enrich'],
},
{
clusters: ['cluster5', 'cluster6'],
privileges: ['monitor_enrich', 'custom-privilege'],
},
],
indices: [],
run_as: [],
},
}}
onChange={jest.fn()}
editable
validator={new RoleValidator()}
availableRemoteClusterPrivileges={['monitor_enrich']}
license={licenseMock.create()}
/>
</KibanaContextProvider>
);
expect(wrapper.find(RemoteClusterPrivilegesForm)).toHaveLength(3);
});
test('it renders fields as disabled when not editable', async () => {
const props = {
role: {
name: '',
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
{
clusters: ['cluster3', 'cluster4'],
privileges: ['monitor_enrich'],
},
],
indices: [],
run_as: [],
},
},
onChange: jest.fn(),
editable: false,
validator: new RoleValidator(),
availableRemoteClusterPrivileges: ['monitor_enrich'],
license: licenseMock.create(),
};
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<RemoteClusterPrivileges {...props} />
</KibanaContextProvider>
);
expect(
wrapper
.find('RemoteClusterPrivilegesForm')
.everyWhere((component) => component.prop('isRoleReadOnly'))
).toBe(true);
});
test('it renders fields as disabled when `allowRemoteClusterPrivileges` is set to false', async () => {
const license = licenseMock.create();
license.getFeatures.mockReturnValue({
allowRemoteClusterPrivileges: false,
} as SecurityLicenseFeatures);
const props = {
role: {
name: '',
kibana: [],
elasticsearch: {
cluster: [],
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
{
clusters: ['cluster3', 'cluster4'],
privileges: ['monitor_enrich'],
},
],
indices: [],
run_as: [],
},
},
onChange: jest.fn(),
editable: false,
validator: new RoleValidator(),
availableRemoteClusterPrivileges: ['monitor_enrich'],
license: licenseMock.create(),
};
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<RemoteClusterPrivileges {...props} />
</KibanaContextProvider>
);
expect(
wrapper
.find('RemoteClusterPrivilegesForm')
.everyWhere((component) => component.prop('isRoleReadOnly'))
).toBe(true);
});

View file

@ -0,0 +1,147 @@
/*
* 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Cluster } from '@kbn/remote-clusters-plugin/public';
import { RemoteClusterPrivilegesForm } from './remote_cluster_privileges_form';
import type { Role, RoleRemoteClusterPrivilege, SecurityLicense } from '../../../../../../common';
import { isRoleReadOnly } from '../../../../../../common/model';
import type { RoleValidator } from '../../validate_role';
interface Props {
remoteClusters?: Cluster[];
role: Role;
availableRemoteClusterPrivileges: string[];
license: SecurityLicense;
onChange: (role: Role) => void;
validator: RoleValidator;
editable?: boolean;
}
export const RemoteClusterPrivileges: React.FunctionComponent<Props> = ({
remoteClusters,
license,
availableRemoteClusterPrivileges,
role,
editable,
onChange,
validator,
}) => {
const remoteClusterPrivileges = useMemo(() => role.elasticsearch.remote_cluster ?? [], [role]);
const remoteClusterPrivilegesDisabled = useMemo(() => {
const { allowRemoteClusterPrivileges } = license.getFeatures();
return !allowRemoteClusterPrivileges;
}, [license]);
const isReadOnly = useMemo(
() => !editable || isRoleReadOnly(role) || remoteClusterPrivilegesDisabled,
[role, editable, remoteClusterPrivilegesDisabled]
);
const onRoleChange = useCallback(
(remoteCluster: RoleRemoteClusterPrivilege[]) => {
const roleDraft = {
...role,
elasticsearch: {
...role.elasticsearch,
remote_cluster: remoteCluster,
},
};
onChange(roleDraft);
},
[onChange, role]
);
const addRemoteClusterPrivilege = useCallback(() => {
const newRemoteClusterPrivileges = [
...remoteClusterPrivileges,
{
clusters: [],
privileges: [],
},
];
onRoleChange(newRemoteClusterPrivileges);
}, [onRoleChange, remoteClusterPrivileges]);
const onRemoteClusterPrivilegeChange = useCallback(
(privilegeIndex: number) => (updatedPrivilege: RoleRemoteClusterPrivilege) => {
const newRemoteClusterPrivileges = [...remoteClusterPrivileges];
newRemoteClusterPrivileges[privilegeIndex] = updatedPrivilege;
onRoleChange(newRemoteClusterPrivileges);
},
[onRoleChange, remoteClusterPrivileges]
);
const onRemoteClusterPrivilegeDelete = useCallback(
(privilegeIndex: number) => () => {
const newRemoteClusterPrivileges = [...remoteClusterPrivileges];
newRemoteClusterPrivileges.splice(privilegeIndex, 1);
onRoleChange(newRemoteClusterPrivileges);
},
[onRoleChange, remoteClusterPrivileges]
);
return (
<>
{remoteClusterPrivileges.map((remoteClusterPrivilege, i) => (
<RemoteClusterPrivilegesForm
key={i}
isRoleReadOnly={isReadOnly}
formIndex={i}
validator={validator}
availableRemoteClusterPrivileges={availableRemoteClusterPrivileges}
remoteClusterPrivilege={remoteClusterPrivilege}
remoteClusters={remoteClusters}
onChange={onRemoteClusterPrivilegeChange(i)}
onDelete={onRemoteClusterPrivilegeDelete(i)}
/>
))}
{editable && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
iconType="plusInCircle"
onClick={addRemoteClusterPrivilege}
disabled={remoteClusterPrivilegesDisabled}
data-test-subj="addRemoteClusterPrivilegesButton"
>
<FormattedMessage
id="xpack.security.management.editRole.elasticSearchPrivileges.addRemoteClusterPrivilegesButtonLabel"
defaultMessage="Add remote cluster privilege"
/>
</EuiButton>
</EuiFlexItem>
{remoteClusterPrivilegesDisabled && (
<EuiFlexItem grow={false}>
<EuiIconTip
content={
<FormattedMessage
id="xpack.security.management.editRole.elasticSearchPrivileges.remoteClusterPrivilegesLicenseMissing"
defaultMessage="Your license does not allow configuring remote cluster privileges"
/>
}
position="right"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
)}
</>
);
};

View file

@ -0,0 +1,182 @@
/*
* 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 { EuiButtonIcon } from '@elastic/eui';
import type { EuiComboBoxProps } from '@elastic/eui';
import React from 'react';
import '@kbn/code-editor-mock/jest_helper';
import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers';
import { RemoteClusterPrivilegesForm } from './remote_cluster_privileges_form';
import { RoleValidator } from '../../validate_role';
test('it renders without crashing', () => {
const wrapper = shallowWithIntl(
<RemoteClusterPrivilegesForm
remoteClusterPrivilege={{
clusters: ['cluster1'],
privileges: ['monitor_enrich'],
}}
formIndex={0}
availableRemoteClusterPrivileges={['monitor_enrich']}
isRoleReadOnly={false}
validator={new RoleValidator()}
onChange={jest.fn()}
onDelete={jest.fn()}
/>
);
expect(wrapper).toMatchSnapshot();
});
test('it allows for custom remote cluster input', () => {
const onChange = jest.fn();
const wrapper = mountWithIntl(
<RemoteClusterPrivilegesForm
remoteClusterPrivilege={{
clusters: ['cluster1'],
privileges: ['monitor_enrich'],
}}
formIndex={0}
availableRemoteClusterPrivileges={['monitor_enrich']}
isRoleReadOnly={false}
validator={new RoleValidator()}
onChange={onChange}
onDelete={jest.fn()}
/>
);
const privilegesSelect = wrapper.find(
'EuiComboBox[data-test-subj="remoteClusterClustersInput0"]'
);
(privilegesSelect.props() as any).onCreateOption('custom-cluster');
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ clusters: ['cluster1', 'custom-cluster'] })
);
});
test('it does not allow for custom remote cluster privileges', () => {
const onChange = jest.fn();
const wrapper = mountWithIntl(
<RemoteClusterPrivilegesForm
remoteClusterPrivilege={{
clusters: ['cluster1'],
privileges: ['monitor_enrich'],
}}
formIndex={0}
availableRemoteClusterPrivileges={['monitor_enrich']}
isRoleReadOnly={false}
validator={new RoleValidator()}
onChange={onChange}
onDelete={jest.fn()}
/>
);
const privilegesSelect = wrapper.find(
'EuiComboBox[data-test-subj="remoteClusterPrivilegesInput0"]'
);
expect((privilegesSelect.props() as EuiComboBoxProps<unknown>).onCreateOption).toBe(undefined);
});
test('it allows for custom remote cluster clusters input', () => {
const onChange = jest.fn();
const wrapper = mountWithIntl(
<RemoteClusterPrivilegesForm
remoteClusterPrivilege={{
clusters: ['cluster1'],
privileges: ['monitor_enrich'],
}}
formIndex={0}
availableRemoteClusterPrivileges={['monitor_enrich']}
isRoleReadOnly={false}
validator={new RoleValidator()}
onChange={onChange}
onDelete={jest.fn()}
/>
);
const clustersSelect = wrapper.find('EuiComboBox[data-test-subj="remoteClusterClustersInput0"]');
(clustersSelect.props() as any).onCreateOption('cluster2');
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ clusters: ['cluster1', 'cluster2'] })
);
});
test('it renders fields as disabled when isRoleReadOnly is true', () => {
const onChange = jest.fn();
const wrapper = mountWithIntl(
<RemoteClusterPrivilegesForm
remoteClusterPrivilege={{
clusters: ['cluster1'],
privileges: ['monitor_enrich'],
}}
formIndex={0}
availableRemoteClusterPrivileges={['monitor_enrich']}
isRoleReadOnly={true}
validator={new RoleValidator()}
onChange={onChange}
onDelete={jest.fn()}
/>
);
const privilegesSelect = wrapper.find(
'EuiComboBox[data-test-subj="remoteClusterPrivilegesInput0"]'
);
expect(privilegesSelect.prop('isDisabled')).toBe(true);
const clustersSelect = wrapper.find('EuiComboBox[data-test-subj="remoteClusterClustersInput0"]');
expect(clustersSelect.prop('isDisabled')).toBe(true);
});
describe('delete button', () => {
const props = {
remoteClusterPrivilege: {
clusters: ['cluster1'],
privileges: ['monitor_enrich'],
},
formIndex: 0,
availableRemoteClusterPrivileges: ['monitor_enrich'],
isRoleReadOnly: false,
validator: new RoleValidator(),
onChange: jest.fn(),
onDelete: jest.fn(),
intl: {} as any,
};
test('it is hidden when isRoleReadOnly is true', () => {
const testProps = {
...props,
isRoleReadOnly: true,
};
const wrapper = mountWithIntl(<RemoteClusterPrivilegesForm {...testProps} />);
expect(wrapper.find(EuiButtonIcon)).toHaveLength(0);
});
test('it is shown when isRoleReadOnly is false', () => {
const testProps = {
...props,
isRoleReadOnly: false,
};
const wrapper = mountWithIntl(<RemoteClusterPrivilegesForm {...testProps} />);
expect(wrapper.find(EuiButtonIcon)).toHaveLength(1);
});
test('it invokes onDelete when clicked', () => {
const testProps = {
...props,
isRoleReadOnly: false,
};
const wrapper = mountWithIntl(<RemoteClusterPrivilegesForm {...testProps} />);
wrapper.find(EuiButtonIcon).simulate('click');
expect(testProps.onDelete).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,170 @@
/*
* 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 type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiButtonIcon,
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import React, { Fragment, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Cluster } from '@kbn/remote-clusters-plugin/public';
import { RemoteClusterComboBox } from './remote_clusters_combo_box';
import type { RoleRemoteClusterPrivilege } from '../../../../../../common';
import type { RoleValidator } from '../../validate_role';
const fromOption = (option: EuiComboBoxOptionOption) => option.label;
const toOption = (value: string): EuiComboBoxOptionOption => ({ label: value });
interface Props {
formIndex: number;
remoteClusterPrivilege: RoleRemoteClusterPrivilege;
remoteClusters?: Cluster[];
availableRemoteClusterPrivileges: string[];
onChange: (remoteClusterPrivilege: RoleRemoteClusterPrivilege) => void;
onDelete: () => void;
isRoleReadOnly: boolean;
validator: RoleValidator;
}
export const RemoteClusterPrivilegesForm: React.FunctionComponent<Props> = ({
isRoleReadOnly,
remoteClusters = [],
formIndex,
validator,
remoteClusterPrivilege,
availableRemoteClusterPrivileges,
onChange,
onDelete,
}) => {
const onCreateClusterOption = useCallback(
(option: string) => {
const nextClusters = (remoteClusterPrivilege.clusters ?? []).concat([option]);
onChange({
...remoteClusterPrivilege,
clusters: nextClusters,
});
},
[remoteClusterPrivilege, onChange]
);
const onClustersChange = useCallback(
(nextOptions: EuiComboBoxOptionOption[]) => {
const clusters = nextOptions.map(fromOption);
onChange({
...remoteClusterPrivilege,
clusters,
});
},
[onChange, remoteClusterPrivilege]
);
const onPrivilegeChange = useCallback(
(newPrivileges: EuiComboBoxOptionOption[]) => {
onChange({
...remoteClusterPrivilege,
privileges: newPrivileges.map(fromOption),
});
},
[remoteClusterPrivilege, onChange]
);
return (
<Fragment>
<EuiSpacer size="m" />
<EuiFlexGroup
alignItems="center"
responsive={false}
className="remote-cluster-privilege-form"
>
<EuiFlexItem>
<EuiPanel color="subdued">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.management.editRole.remoteClusterPrivilegeForm.clustersFormRowLabel"
defaultMessage="Remote clusters"
/>
}
fullWidth
{...validator.validateRemoteClusterPrivilegeClusterField(remoteClusterPrivilege)}
>
<RemoteClusterComboBox
data-test-subj={`remoteClusterClustersInput${formIndex}`}
selectedOptions={(remoteClusterPrivilege.clusters ?? []).map(toOption)}
onCreateOption={onCreateClusterOption}
onChange={onClustersChange}
isDisabled={isRoleReadOnly}
placeholder={i18n.translate(
'xpack.security.management.editRole.remoteClusterPrivilegeForm.clustersPlaceholder',
{ defaultMessage: 'Add a remote cluster…' }
)}
remoteClusters={remoteClusters}
type="remote_cluster"
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.management.editRole.remoteClusterPrivilegeForm.privilegesFormRowLabel"
defaultMessage="Privileges"
/>
}
fullWidth
{...validator.validateRemoteClusterPrivilegePrivilegesField(
remoteClusterPrivilege
)}
>
<EuiComboBox
data-test-subj={`remoteClusterPrivilegesInput${formIndex}`}
options={availableRemoteClusterPrivileges.map(toOption)}
selectedOptions={remoteClusterPrivilege.privileges.map(toOption)}
onChange={onPrivilegeChange}
isDisabled={isRoleReadOnly}
placeholder={i18n.translate(
'xpack.security.management.editRole.remoteClusterPrivilegeForm.privilegesPlaceholder',
{ defaultMessage: 'Add an action…' }
)}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{!isRoleReadOnly && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.security.management.editRole.remoteClusterPrivilegeForm.deleteRemoteClusterPrivilegeAriaLabel',
{ defaultMessage: 'Delete remote cluster privilege' }
)}
color="danger"
onClick={onDelete}
iconType="trash"
data-test-subj={`deleteRemoteClusterPrivilegesButton${formIndex}`}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</Fragment>
);
};

View file

@ -0,0 +1,106 @@
/*
* 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 '@kbn/code-editor-mock/jest_helper';
import { shallowWithIntl } from '@kbn/test-jest-helpers';
import { RemoteClusterComboBox } from './remote_clusters_combo_box';
test('it renders without crashing', () => {
const wrapper = shallowWithIntl(
<RemoteClusterComboBox
type="remote_cluster"
dat-test-subj="remoteClusterClustersInput0"
remoteClusters={[
{
name: 'test1',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'certificate',
},
{
name: 'test2',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'api_key',
},
{
name: 'test3',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'api_key',
},
]}
/>
);
expect(wrapper).toMatchSnapshot();
});
test('should render clusters field', () => {
const wrapper = shallowWithIntl(
<RemoteClusterComboBox
onChange={jest.fn()}
type="remote_cluster"
remoteClusters={[
{
name: 'test1',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'certificate',
},
{
name: 'test2',
mode: 'proxy',
isConnected: false,
initialConnectTimeout: '30s',
skipUnavailable: false,
proxyAddress: 'localhost:9400',
proxySocketConnections: 18,
connectedSocketsCount: 0,
serverName: 'localhost',
securityModel: 'api_key',
},
]}
/>
);
const clustersInput = wrapper.find('EuiComboBox');
expect(clustersInput.prop('options')).toEqual([
{ label: 'test2' },
{ label: expect.anything(), isGroupLabelOption: true },
{
label: 'test1',
disabled: true,
append: expect.anything(),
},
]);
});

View file

@ -0,0 +1,87 @@
/*
* 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 type { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
import { EuiComboBox, EuiIconTip } from '@elastic/eui';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Cluster } from '@kbn/remote-clusters-plugin/public';
const API_KEY_SECURITY_MODEL = 'api_key';
interface Props extends Omit<EuiComboBoxProps<string | number | string[] | undefined>, 'options'> {
remoteClusters: Cluster[];
type: 'remote_cluster' | 'remote_indexes';
}
export const RemoteClusterComboBox: React.FunctionComponent<Props> = ({
remoteClusters,
type,
...restProps
}) => {
const remoteClusterOptions = useMemo<EuiComboBoxOptionOption[]>(() => {
const { incompatible, remote } = remoteClusters.reduce<{
remote: EuiComboBoxOptionOption[];
incompatible: EuiComboBoxOptionOption[];
}>(
(data, item) => {
const disabled = item.securityModel !== API_KEY_SECURITY_MODEL;
if (!disabled) {
data.remote.push({ label: item.name });
return data;
}
data.incompatible.push({
label: item.name,
disabled,
append: disabled ? (
<EuiIconTip
type="warning"
color="inherit"
content={
type === 'remote_cluster' ? (
<FormattedMessage
id="xpack.security.management.editRole.remoteClusterPrivilegeForm.remoteClusterSecurityModelWarning"
defaultMessage="This cluster is configured with the certificate based security model and does not support remote cluster privileges. Connect this cluster with the API key based security model instead to use remote cluster privileges."
/>
) : (
<FormattedMessage
id="xpack.security.management.editRole.indexPrivilegeForm.remoteIndicesSecurityModelWarning"
defaultMessage="This cluster is configured with the certificate based security model and does not support remote index privileges. Connect this cluster with the API key based security model instead to use remote index privileges."
/>
)
}
/>
) : undefined,
});
return data;
},
{
incompatible: [],
remote: [],
}
);
if (incompatible.length) {
remote.push(
{
label: 'Incompatible clusters',
isGroupLabelOption: true,
},
...incompatible
);
}
return remote;
}, [remoteClusters, type]);
return <EuiComboBox {...restProps} options={remoteClusterOptions} />;
};

View file

@ -7,7 +7,12 @@
import { i18n } from '@kbn/i18n';
import type { Role, RoleIndexPrivilege, RoleRemoteIndexPrivilege } from '../../../../common';
import type {
Role,
RoleIndexPrivilege,
RoleRemoteClusterPrivilege,
RoleRemoteIndexPrivilege,
} from '../../../../common';
import { MAX_NAME_LENGTH, NAME_REGEX } from '../../../../common/constants';
interface RoleValidatorOptions {
@ -81,6 +86,27 @@ export class RoleValidator {
return valid();
}
public validateRemoteClusterPrivileges(role: Role): RoleValidationResult {
if (!this.shouldValidate) {
return valid();
}
const areRemoteClustersInvalid = role.elasticsearch.remote_cluster?.some(
(remoteClusterPrivilege) => {
return (
this.validateRemoteClusterPrivilegeClusterField(remoteClusterPrivilege).isInvalid ||
this.validateRemoteClusterPrivilegePrivilegesField(remoteClusterPrivilege).isInvalid
);
}
);
if (areRemoteClustersInvalid) {
return invalid();
}
return valid();
}
public validateIndexPrivileges(role: Role): RoleValidationResult {
if (!this.shouldValidate) {
return valid();
@ -239,6 +265,58 @@ export class RoleValidator {
return valid();
}
public validateRemoteClusterPrivilegeClusterField(
remoteClusterPrivilege: RoleRemoteClusterPrivilege
): RoleValidationResult {
if (!this.shouldValidate) {
return valid();
}
// Ignore if all other fields are empty
if (!remoteClusterPrivilege.privileges.length) {
return valid();
}
if (!remoteClusterPrivilege.clusters.length) {
return invalid(
i18n.translate(
'xpack.security.management.editRole.validateRole.oneClusterRequiredWarningMessage',
{
defaultMessage: 'Enter or select at least one cluster',
}
)
);
}
return valid();
}
public validateRemoteClusterPrivilegePrivilegesField(
remoteClusterPrivilege: RoleRemoteClusterPrivilege
): RoleValidationResult {
if (!this.shouldValidate) {
return valid();
}
// Ignore if all other fields are empty
if (!remoteClusterPrivilege.clusters.length) {
return valid();
}
if (!remoteClusterPrivilege.privileges.length) {
return invalid(
i18n.translate(
'xpack.security.management.editRole.validateRole.oneRemoteClusterPrivilegeRequiredWarningMessage',
{
defaultMessage: 'Enter or select at least one privilege',
}
)
);
}
return valid();
}
public validateSelectedSpaces(
spaceIds: string[],
privilege: string | null
@ -313,12 +391,15 @@ export class RoleValidator {
const { isInvalid: areIndicesInvalid } = this.validateIndexPrivileges(role);
const { isInvalid: areRemoteIndicesInvalid } = this.validateRemoteIndexPrivileges(role);
const { isInvalid: areSpacePrivilegesInvalid } = this.validateSpacePrivileges(role);
const { isInvalid: areRemoteClusterPrivilegesInvalid } =
this.validateRemoteClusterPrivileges(role);
if (
isNameInvalid ||
areIndicesInvalid ||
areRemoteIndicesInvalid ||
areSpacePrivilegesInvalid
areSpacePrivilegesInvalid ||
areRemoteClusterPrivilegesInvalid
) {
return invalid();
}

View file

@ -16,6 +16,7 @@ const roles = [
{
name: 'global-base-all',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -33,6 +34,7 @@ const roles = [
{
name: 'global-base-read',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -50,6 +52,7 @@ const roles = [
{
name: 'global-foo-all',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -67,6 +70,7 @@ const roles = [
{
name: 'global-foo-read',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -84,6 +88,7 @@ const roles = [
{
name: 'global-malformed',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -101,6 +106,7 @@ const roles = [
{
name: 'default-base-all',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -118,6 +124,7 @@ const roles = [
{
name: 'default-base-read',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -135,6 +142,7 @@ const roles = [
{
name: 'default-foo-all',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -152,6 +160,7 @@ const roles = [
{
name: 'default-foo-read',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -169,6 +178,7 @@ const roles = [
{
name: 'default-malformed',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{
@ -294,6 +304,7 @@ describe('#transformElasticsearchRoleToRole', () => {
const role = {
name: 'global-all',
cluster: [],
remote_cluster: [],
indices: [],
applications: [
{

View file

@ -28,6 +28,7 @@ export type ElasticsearchRole = Pick<
resources: string[];
}>;
cluster: Role['elasticsearch']['cluster'];
remote_cluster: Role['elasticsearch']['remote_cluster'];
indices: Role['elasticsearch']['indices'];
remote_indices?: Role['elasticsearch']['remote_indices'];
run_as: Role['elasticsearch']['run_as'];
@ -56,6 +57,7 @@ export function transformElasticsearchRoleToRole(
transient_metadata: elasticsearchRole.transient_metadata,
elasticsearch: {
cluster: elasticsearchRole.cluster,
remote_cluster: elasticsearchRole.remote_cluster,
indices: elasticsearchRole.indices,
remote_indices: elasticsearchRole.remote_indices,
run_as: elasticsearchRole.run_as,

View file

@ -66,6 +66,7 @@ describe('#getPrivilegeDeprecationsService', () => {
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": undefined,
"remote_indices": undefined,
"run_as": Array [],
},
@ -139,6 +140,7 @@ describe('#getPrivilegeDeprecationsService', () => {
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"remote_cluster": undefined,
"remote_indices": undefined,
"run_as": Array [],
},

View file

@ -21,7 +21,10 @@ export function defineGetBuiltinPrivilegesRoutes({ router }: RouteDefinitionPara
: [privileges.index];
privileges.index = indexPriviledges.filter((privilege) => privilege !== 'none');
return response.ok({ body: privileges });
// TODO: remove hardcoded value once ES returns built-in privileges for remote_cluster
const remoteClusterPrivileges = ['monitor_enrich'];
return response.ok({ body: { ...privileges, remote_cluster: remoteClusterPrivileges } });
}
);
}

View file

@ -108,10 +108,10 @@ describe('Put payload schema', () => {
kibana: [{ spaces: ['foo-*'] }],
})
).toThrowErrorMatchingInlineSnapshot(`
"[kibana.0.spaces]: types that failed validation:
- [kibana.0.spaces.0.0]: expected value to equal [*]
- [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed"
`);
"[kibana.0.spaces]: types that failed validation:
- [kibana.0.spaces.0.0]: expected value to equal [*]
- [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed"
`);
});
test(`can't assign space and global in same entry`, () => {
@ -120,10 +120,10 @@ describe('Put payload schema', () => {
kibana: [{ spaces: ['*', 'foo-space'] }],
})
).toThrowErrorMatchingInlineSnapshot(`
"[kibana.0.spaces]: types that failed validation:
- [kibana.0.spaces.0.1]: expected value to equal [*]
- [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed"
`);
"[kibana.0.spaces]: types that failed validation:
- [kibana.0.spaces.0.1]: expected value to equal [*]
- [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed"
`);
});
test(`only allows known Kibana space base privileges`, () => {
@ -424,8 +424,72 @@ describe('Put payload schema', () => {
`);
});
test('passes through remote_cluster when specified', () => {
expect(
getPutPayloadSchema(() => basePrivilegeNamesMap).validate({
elasticsearch: {
remote_cluster: [
{
privileges: ['monitor_enrich'],
clusters: ['my_remote*'],
},
],
},
})
).toMatchInlineSnapshot(`
Object {
"elasticsearch": Object {
"remote_cluster": Array [
Object {
"clusters": Array [
"my_remote*",
],
"privileges": Array [
"monitor_enrich",
],
},
],
},
}
`);
});
test(`doesn't allow empty privilege for remote_cluster`, () => {
expect(() =>
getPutPayloadSchema(() => basePrivilegeNamesMap).validate({
elasticsearch: {
remote_cluster: [
{
privileges: [],
clusters: ['cluster1'],
},
],
},
})
).toThrowErrorMatchingInlineSnapshot(
`"[elasticsearch.remote_cluster.0.privileges]: array size is [0], but cannot be smaller than [1]"`
);
});
test(`doesn't allow empty clusters for remote_cluster`, () => {
expect(() =>
getPutPayloadSchema(() => basePrivilegeNamesMap).validate({
elasticsearch: {
remote_cluster: [
{
privileges: ['enrich_monitor'],
clusters: [],
},
],
},
})
).toThrowErrorMatchingInlineSnapshot(
`"[elasticsearch.remote_cluster.0.clusters]: array size is [0], but cannot be smaller than [1]"`
);
});
// This is important for backwards compatibility
test('does not set default value for remote_indices when not specified', () => {
test('does not set default value for remote_indices/remote_cluster when not specified', () => {
expect(getPutPayloadSchema(() => basePrivilegeNamesMap).validate({})).toMatchInlineSnapshot(`
Object {
"elasticsearch": Object {},

View file

@ -19,6 +19,7 @@ export const transformPutPayloadToElasticsearchRole = (
) => {
const {
elasticsearch = {
remote_cluster: undefined,
cluster: undefined,
indices: undefined,
remote_indices: undefined,
@ -34,6 +35,7 @@ export const transformPutPayloadToElasticsearchRole = (
...(rolePayload.description && { description: rolePayload.description }),
metadata: rolePayload.metadata,
cluster: elasticsearch.cluster || [],
remote_cluster: elasticsearch.remote_cluster,
indices: elasticsearch.indices || [],
remote_indices: elasticsearch.remote_indices,
run_as: elasticsearch.run_as || [],

View file

@ -325,6 +325,7 @@ describe('PUT role', () => {
body: {
cluster: [],
indices: [],
remote_cluster: undefined,
remote_indices: undefined,
run_as: [],
applications: [],
@ -936,5 +937,59 @@ describe('PUT role', () => {
result: undefined,
},
});
putRoleTest(`creates role with remote_cluster privileges`, {
name: 'foo-role-remote-cluster',
payload: {
kibana: [],
elasticsearch: {
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
{
clusters: ['cluster3', 'cluster4'],
privileges: ['monitor_enrich'],
},
],
},
},
apiResponses: {
get: () => ({}),
put: () => {},
},
asserts: {
recordSubFeaturePrivilegeUsage: false,
apiArguments: {
get: [{ name: 'foo-role-remote-cluster' }, { ignore: [404] }],
put: [
{
name: 'foo-role-remote-cluster',
body: {
applications: [],
cluster: [],
indices: [],
remote_indices: undefined,
run_as: [],
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
{
clusters: ['cluster3', 'cluster4'],
privileges: ['monitor_enrich'],
},
],
metadata: undefined,
},
},
],
},
statusCode: 204,
result: undefined,
},
});
});
});

View file

@ -98,6 +98,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: true,
hasCompatibleRealms: true,
canUseRemoteIndices: true,
canUseRemoteClusters: true,
},
},
});
@ -122,6 +123,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: true,
hasCompatibleRealms: true,
canUseRemoteIndices: true,
canUseRemoteClusters: true,
},
},
});
@ -141,6 +143,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: true,
hasCompatibleRealms: true,
canUseRemoteIndices: false,
canUseRemoteClusters: false,
},
},
}
@ -166,6 +169,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: false,
hasCompatibleRealms: true,
canUseRemoteIndices: true,
canUseRemoteClusters: true,
},
},
});
@ -190,6 +194,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: true,
hasCompatibleRealms: true,
canUseRemoteIndices: true,
canUseRemoteClusters: true,
},
},
});
@ -218,6 +223,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: true,
hasCompatibleRealms: false,
canUseRemoteIndices: true,
canUseRemoteClusters: true,
},
},
});
@ -249,6 +255,7 @@ describe('GET role mappings feature check', () => {
canUseStoredScripts: true,
hasCompatibleRealms: false,
canUseRemoteIndices: false,
canUseRemoteClusters: false,
},
},
}

View file

@ -131,7 +131,8 @@ async function getEnabledRoleMappingsFeatures(esClient: ElasticsearchClient, log
hasCompatibleRealms,
canUseStoredScripts,
canUseInlineScripts,
canUseRemoteIndices: !!xpackUsage.remote_clusters,
canUseRemoteIndices: Boolean(xpackUsage.remote_clusters),
canUseRemoteClusters: Boolean(xpackUsage.remote_clusters),
};
}

View file

@ -167,6 +167,7 @@ describe('Login view routes', () => {
allowRoleDocumentLevelSecurity: true,
allowRoleFieldLevelSecurity: false,
allowRoleRemoteIndexPrivileges: false,
allowRemoteClusterPrivileges: false,
layout: 'error-es-unavailable',
showLinks: false,
showRoleMappingsManagement: true,

View file

@ -24,7 +24,7 @@ export default function ({ getService }: FtrProviderContext) {
const sampleOfExpectedIndexPrivileges = ['create', 'index', 'delete'];
const payload = response.body;
expect(Object.keys(payload).sort()).to.eql(['cluster', 'index']);
expect(Object.keys(payload).sort()).to.eql(['cluster', 'index', 'remote_cluster']);
sampleOfExpectedClusterPrivileges.forEach((privilege) =>
expect(payload.cluster).to.contain(privilege)

View file

@ -17,5 +17,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./field_level_security'));
loadTestFile(require.resolve('./user_email'));
loadTestFile(require.resolve('./role_mappings'));
loadTestFile(require.resolve('./remote_cluster_security_roles'));
});
}

View file

@ -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 expect from '@kbn/expect';
import { keyBy } from 'lodash';
import { FtrProviderContext } from '../../ftr_provider_context';
const EDIT_ROLES_PATH = 'security/roles/edit';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const browser = getService('browser');
const log = getService('log');
const security = getService('security');
const PageObjects = getPageObjects(['security', 'common', 'header', 'discover', 'settings']);
const kibanaServer = getService('kibanaServer');
describe('Remote Cluster Privileges', function () {
const customRole = 'rc-custom-role';
before('initialize tests', async () => {
await kibanaServer.savedObjects.cleanStandardList();
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/security/dlstest');
await browser.setWindowSize(1600, 1000);
await PageObjects.common.navigateToApp('settings');
await PageObjects.settings.createIndexPattern('dlstest', null);
await security.testUser.setRoles(['cluster_security_manager', 'kibana_admin']);
await PageObjects.settings.navigateTo();
await PageObjects.security.clickElasticsearchRoles();
});
it(`should add new role ${customRole} with remote cluster privileges`, async function () {
await PageObjects.security.addRole(customRole, {
elasticsearch: {
indices: [
{
names: ['dlstest'],
privileges: ['read', 'view_index_metadata'],
},
],
remote_cluster: [
{
clusters: ['cluster1', 'cluster2'],
privileges: ['monitor_enrich'],
},
],
},
});
const roles = keyBy(await PageObjects.security.getElasticsearchRoles(), 'rolename');
log.debug('actualRoles = %j', roles);
expect(roles).to.have.key(customRole);
expect(roles[customRole].reserved).to.be(false);
});
it(`should update role ${customRole} with remote cluster privileges`, async function () {
await PageObjects.settings.clickLinkText(customRole);
const currentUrl = await browser.getCurrentUrl();
expect(currentUrl).to.contain(EDIT_ROLES_PATH);
const { clusters: currentClusters, privileges: currentPrivileges } =
await PageObjects.security.getRemoteClusterPrivilege(0);
expect(currentClusters).to.eql(['cluster1', 'cluster2']);
expect(currentPrivileges).to.eql(['monitor_enrich']);
await PageObjects.security.deleteRemoteClusterPrivilege(0);
await PageObjects.security.addRemoteClusterPrivilege({
clusters: ['cluster3', 'cluster4'],
privileges: ['monitor_enrich'],
});
await PageObjects.security.saveRole();
});
after('logout', async () => {
// NOTE: Logout needs to happen before anything else to avoid flaky behavior
await PageObjects.security.forceLogout();
await security.role.delete(customRole);
await security.testUser.restoreDefaults();
});
});
}

View file

@ -6,7 +6,7 @@
*/
import { adminTestUser } from '@kbn/test';
import { AuthenticatedUser, Role } from '@kbn/security-plugin/common';
import { AuthenticatedUser, Role, RoleRemoteClusterPrivilege } from '@kbn/security-plugin/common';
import type { UserFormValues } from '@kbn/security-plugin/public/management/users/edit_user/user_form';
import { Key } from 'selenium-webdriver';
import { FtrService } from '../ftr_provider_context';
@ -600,9 +600,54 @@ export class SecurityPageObject extends FtrService {
return confirmText;
}
async addRemoteClusterPrivilege(privilege: RoleRemoteClusterPrivilege, index = 0) {
this.log.debug('addRemoteClusterPrivilege, index = ', index);
await this.testSubjects.click('addRemoteClusterPrivilegesButton');
for (const cluster of privilege.clusters) {
await this.comboBox.setCustom(`remoteClusterClustersInput${index}`, cluster);
}
for (const clusterPrivilege of privilege.privileges) {
await this.comboBox.setCustom(`remoteClusterPrivilegesInput${index}`, clusterPrivilege);
}
}
async saveRole() {
this.log.debug('click save button');
await this.testSubjects.click('roleFormSaveButton');
// Signifies that the role management page redirected back to the role grid page,
// and successfully refreshed the grid
await this.testSubjects.existOrFail('roleRow');
}
async deleteRemoteClusterPrivilege(index: number) {
this.log.debug('deleteRemoteClusterPrivilege, index = ', index);
await this.testSubjects.click(`deleteRemoteClusterPrivilegesButton${index}`);
}
async getRemoteClusterPrivilege(index: number) {
this.log.debug('getRemoteClusterPrivilege, index = ', index);
const clusterOptions = await this.comboBox.getComboBoxSelectedOptions(
`remoteClusterClustersInput${index}`
);
const privilegeOptions = await this.comboBox.getComboBoxSelectedOptions(
`remoteClusterPrivilegesInput${index}`
);
return {
clusters: clusterOptions,
privileges: privilegeOptions,
};
}
async addRole(
roleName: string,
roleObj: { elasticsearch: Pick<Role['elasticsearch'], 'indices'> }
roleObj: { elasticsearch: Pick<Role['elasticsearch'], 'indices' | 'remote_cluster'> }
) {
const self = this;
@ -667,12 +712,18 @@ export class SecurityPageObject extends FtrService {
await addGrantedField(roleObj.elasticsearch.indices[0].field_security!.grant!);
}
this.log.debug('click save button');
await this.testSubjects.click('roleFormSaveButton');
if (roleObj.elasticsearch.remote_cluster) {
this.log.debug('adding remote_cluster privileges');
// Signifies that the role management page redirected back to the role grid page,
// and successfully refreshed the grid
await this.testSubjects.existOrFail('roleRow');
for (const [
index,
remoteClusterPrivilege,
] of roleObj.elasticsearch.remote_cluster.entries()) {
await this.addRemoteClusterPrivilege(remoteClusterPrivilege, index);
}
}
await this.saveRole();
}
async selectRole(role: string) {