Make spaces plugin optional (#149044)

## Summary

The purpose of this PR is to make the spaces plugin an optional
dependency so that it can be disabled in future offerings.

In order to achieve this we are reintroducing the following config
option to disable spaces:

```yaml
xpack.spaces.enabled: false
```

This config option is only available in development mode while we
coordinate updating the rest of our plugins.

## Scope

In order to keep the scope manageable, only the following plugins have
been updated as part of this PR:

- `x-pack/plugins/alerting`
- `x-pack/plugins/security`
- `x-pack/plugins/spaces`

The following plugins will need to be updated separately, by working
with the corresponding teams:

- `x-pack/plugins/cases`
- `x-pack/plugins/enterprise_search`
- `x-pack/plugins/fleet`
- `x-pack/plugins/infra`
- `x-pack/plugins/lens`
- `x-pack/plugins/ml`
- `x-pack/plugins/observability`
- `x-pack/plugins/osquery`
- `x-pack/plugins/synthetics`

## Screenshots

### Kibana chrome without spaces selector

<img width="1073" alt="212935199-dd3bb035-b2f5-4fb6-96e9-5d2093c7992c"
src="https://user-images.githubusercontent.com/190132/213432556-18e1c159-1fb1-4808-ad12-47814de575ed.png">

### Simplified role management screen

<img width="1136" alt="Screenshot 2023-01-19 at 11 21 03"
src="https://user-images.githubusercontent.com/190132/213432595-0ae33abb-85e7-4044-82a2-6cc44304af0e.png">
This commit is contained in:
Thom Heymann 2023-01-31 14:19:43 +00:00 committed by GitHub
parent f7bac2d971
commit c2e538189e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1205 additions and 25 deletions

View file

@ -8,7 +8,10 @@
},
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "alerting"],
"configPath": [
"xpack",
"alerting"
],
"requiredPlugins": [
"actions",
"data",
@ -19,9 +22,16 @@
"features",
"kibanaUtils",
"licensing",
"spaces",
"taskManager"
],
"optionalPlugins": ["usageCollection", "security", "monitoringCollection"],
"extraPublicDirs": ["common", "common/parse_duration"]
}
"optionalPlugins": [
"usageCollection",
"security",
"monitoringCollection",
"spaces"
],
"extraPublicDirs": [
"common",
"common/parse_duration"
]
}

View file

@ -16,7 +16,7 @@ export interface AlertingAuthorizationClientFactoryOpts {
ruleTypeRegistry: RuleTypeRegistry;
securityPluginSetup?: SecurityPluginSetup;
securityPluginStart?: SecurityPluginStart;
getSpace: (request: KibanaRequest) => Promise<Space>;
getSpace: (request: KibanaRequest) => Promise<Space | undefined>;
getSpaceId: (request: KibanaRequest) => string;
features: FeaturesPluginStart;
}
@ -26,7 +26,7 @@ export class AlertingAuthorizationClientFactory {
private ruleTypeRegistry!: RuleTypeRegistry;
private securityPluginStart?: SecurityPluginStart;
private features!: FeaturesPluginStart;
private getSpace!: (request: KibanaRequest) => Promise<Space>;
private getSpace!: (request: KibanaRequest) => Promise<Space | undefined>;
private getSpaceId!: (request: KibanaRequest) => string;
public initialize(options: AlertingAuthorizationClientFactoryOpts) {

View file

@ -20,6 +20,7 @@ import {
TaskManagerSetupContract,
TaskManagerStartContract,
} from '@kbn/task-manager-plugin/server';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import {
KibanaRequest,
@ -162,7 +163,7 @@ export interface AlertingPluginsStart {
features: FeaturesPluginStart;
eventLog: IEventLogClientService;
licensing: LicensingPluginStart;
spaces: SpacesPluginStart;
spaces?: SpacesPluginStart;
security?: SecurityPluginStart;
data: DataPluginStart;
dataViews: DataViewsPluginStart;
@ -417,10 +418,10 @@ export class AlertingPlugin {
securityPluginSetup: security,
securityPluginStart: plugins.security,
async getSpace(request: KibanaRequest) {
return plugins.spaces.spacesService.getActiveSpace(request);
return plugins.spaces?.spacesService.getActiveSpace(request);
},
getSpaceId(request: KibanaRequest) {
return plugins.spaces.spacesService.getSpaceId(request);
return plugins.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID;
},
features: plugins.features,
});
@ -434,7 +435,7 @@ export class AlertingPlugin {
encryptedSavedObjectsClient,
spaceIdToNamespace,
getSpaceId(request: KibanaRequest) {
return plugins.spaces?.spacesService.getSpaceId(request);
return plugins.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID;
},
actions: plugins.actions,
eventLog: plugins.eventLog,

View file

@ -30,8 +30,7 @@ export const registerSettings = (uiSettings: UiSettingsServiceSetup, config: Ban
defaultMessage: 'Banner placement',
}),
description: i18n.translate('xpack.banners.settings.placement.description', {
defaultMessage:
'Display a top banner for this space, above the Elastic header. {subscriptionLink}',
defaultMessage: 'Display a top banner above the Elastic header. {subscriptionLink}',
values: {
subscriptionLink,
},

View file

@ -15,6 +15,8 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { KibanaFeature } from '@kbn/features-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { Space } from '@kbn/spaces-plugin/public';
import { spacesManagerMock } from '@kbn/spaces-plugin/public/spaces_manager/mocks';
import { getUiApi } from '@kbn/spaces-plugin/public/ui_api';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { licenseMock } from '../../../../common/licensing/index.mock';
@ -23,9 +25,14 @@ import { userAPIClientMock } from '../../users/index.mock';
import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges';
import { indicesAPIClientMock, privilegesAPIClientMock, rolesAPIClientMock } from '../index.mock';
import { EditRolePage } from './edit_role_page';
import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section';
import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section';
import { TransformErrorSection } from './privileges/kibana/transform_error_section';
const spacesManager = spacesManagerMock.create();
const { getStartServices } = coreMock.createSetup();
const spacesApiUi = getUiApi({ spacesManager, getStartServices });
const buildFeatures = () => {
return [
new KibanaFeature({
@ -132,10 +139,12 @@ function getProps({
action,
role,
canManageSpaces = true,
spacesEnabled = true,
}: {
action: 'edit' | 'clone';
role?: Role;
canManageSpaces?: boolean;
spacesEnabled?: boolean;
}) {
const rolesAPIClient = rolesAPIClientMock.create();
rolesAPIClient.getRole.mockResolvedValue(role);
@ -162,6 +171,9 @@ function getProps({
const { fatalErrors } = coreMock.createSetup();
const { http, docLinks, notifications } = coreMock.createStart();
http.get.mockImplementation(async (path: any) => {
if (!spacesEnabled) {
throw { response: { status: 404 } }; // eslint-disable-line no-throw-literal
}
if (path === '/api/spaces/space') {
return buildSpaces();
}
@ -183,6 +195,7 @@ function getProps({
fatalErrors,
uiCapabilities: buildUICapabilities(canManageSpaces),
history: scopedHistoryMock.create(),
spacesApiUi,
};
}
@ -411,6 +424,194 @@ describe('<EditRolePage />', () => {
});
});
describe('with spaces disabled', () => {
it('can render readonly view when not enough privileges', async () => {
coreStart.application.capabilities = {
...coreStart.application.capabilities,
roles: {
save: false,
},
};
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
role: {
name: 'my custom role',
metadata: {},
elasticsearch: { cluster: ['all'], indices: [], run_as: ['*'] },
kibana: [{ spaces: ['*'], base: ['all'], feature: {} }],
},
})}
/>
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(wrapper.find('input[data-test-subj="roleFormNameInput"]').prop('disabled')).toBe(true);
expectReadOnlyFormButtons(wrapper);
});
it('can render a reserved role', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
role: {
name: 'superuser',
metadata: { _reserved: true },
elasticsearch: { cluster: ['all'], indices: [], run_as: ['*'] },
kibana: [{ spaces: ['*'], base: ['all'], feature: {} }],
},
})}
/>
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
expect(wrapper.find('input[data-test-subj="roleFormNameInput"]').prop('disabled')).toBe(true);
expectReadOnlyFormButtons(wrapper);
});
it('can render a user defined role', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
role: {
name: 'my custom role',
metadata: {},
elasticsearch: { cluster: ['all'], indices: [], run_as: ['*'] },
kibana: [{ spaces: ['*'], base: ['all'], feature: {} }],
},
})}
/>
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
expect(wrapper.find('input[data-test-subj="roleFormNameInput"]').prop('disabled')).toBe(true);
expectSaveFormButtons(wrapper);
});
it('can render when creating a new role', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage {...getProps({ action: 'edit', spacesEnabled: false })} />
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
expect(wrapper.find('input[data-test-subj="roleFormNameInput"]').prop('disabled')).toBe(
false
);
expectSaveFormButtons(wrapper);
});
it('redirects back to roles when creating a new role without privileges', async () => {
coreStart.application.capabilities = {
...coreStart.application.capabilities,
roles: {
save: false,
},
};
const props = getProps({ action: 'edit', spacesEnabled: false });
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage {...props} />
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(props.history.push).toHaveBeenCalledWith('/');
});
it('can render when cloning an existing role', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
role: {
name: '',
metadata: { _reserved: false },
elasticsearch: {
cluster: ['all', 'manage'],
indices: [
{
names: ['foo*'],
privileges: ['all'],
field_security: { except: ['f'], grant: ['b*'] },
},
],
run_as: ['elastic'],
},
kibana: [{ spaces: ['*'], base: ['all'], feature: {} }],
},
})}
/>
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
expect(wrapper.find('input[data-test-subj="roleFormNameInput"]').prop('disabled')).toBe(
false
);
expectSaveFormButtons(wrapper);
});
it('renders a partial read-only view when there is a transform error', async () => {
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRolePage
{...getProps({
action: 'edit',
spacesEnabled: false,
canManageSpaces: false,
role: {
name: 'my custom role',
metadata: {},
elasticsearch: { cluster: ['all'], indices: [], run_as: ['*'] },
kibana: [],
_transform_error: ['kibana'],
},
})}
/>
</KibanaContextProvider>
);
await waitForRender(wrapper);
expect(wrapper.find(TransformErrorSection)).toHaveLength(1);
expectReadOnlyFormButtons(wrapper);
});
});
it('registers fatal error if features endpoint fails unexpectedly', async () => {
const error = { response: { status: 500 } };
const getFeatures = jest.fn().mockRejectedValue(error);

View file

@ -470,6 +470,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
<KibanaPrivilegesRegion
kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)}
spaces={spaces.list}
spacesEnabled={spaces.enabled}
uiCapabilities={uiCapabilities}
canCustomizeSubFeaturePrivileges={license.getFeatures().allowSubFeaturePrivileges}
editable={!isRoleReadOnly}

View file

@ -42,6 +42,21 @@ exports[`<KibanaPrivileges> renders without crashing 1`] = `
},
]
}
spacesApiUi={
Object {
"components": Object {
"getCopyToSpaceFlyout": [Function],
"getEmbeddableLegacyUrlConflict": [Function],
"getLegacyUrlConflict": [Function],
"getShareToSpaceFlyout": [Function],
"getSpaceAvatar": [Function],
"getSpaceList": [Function],
"getSpacesContextProvider": [Function],
},
"redirectLegacyUrl": [Function],
"useSpaces": [Function],
}
}
uiCapabilities={
Object {
"catalogue": Object {},

View file

@ -8,13 +8,22 @@
import { shallow } from 'enzyme';
import React from 'react';
import { coreMock } from '@kbn/core/public/mocks';
import { spacesManagerMock } from '@kbn/spaces-plugin/public/spaces_manager/mocks';
import { getUiApi } from '@kbn/spaces-plugin/public/ui_api';
import type { Role } from '../../../../../../common/model';
import { KibanaPrivileges } from '../../../model';
import { RoleValidator } from '../../validate_role';
import { KibanaPrivilegesRegion } from './kibana_privileges_region';
import { SimplePrivilegeSection } from './simple_privilege_section';
import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section';
import { TransformErrorSection } from './transform_error_section';
const spacesManager = spacesManagerMock.create();
const { getStartServices } = coreMock.createSetup();
const spacesApiUi = getUiApi({ spacesManager, getStartServices });
const buildProps = () => {
return {
role: {
@ -62,12 +71,15 @@ const buildProps = () => {
onChange: jest.fn(),
validator: new RoleValidator(),
canCustomizeSubFeaturePrivileges: true,
spacesEnabled: true,
spacesApiUi,
};
};
describe('<KibanaPrivileges>', () => {
it('renders without crashing', () => {
expect(shallow(<KibanaPrivilegesRegion {...buildProps()} />)).toMatchSnapshot();
const props = buildProps();
expect(shallow(<KibanaPrivilegesRegion {...props} />)).toMatchSnapshot();
});
it('renders the space-aware privilege form', () => {
@ -76,6 +88,12 @@ describe('<KibanaPrivileges>', () => {
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
});
it('renders simple privilege form when spaces is disabled', () => {
const props = buildProps();
const wrapper = shallow(<KibanaPrivilegesRegion {...props} spacesEnabled={false} />);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
});
it('renders the transform error section when the role has a transform error', () => {
const props = buildProps();
(props.role as Role)._transform_error = ['kibana'];

View file

@ -14,11 +14,13 @@ import type { Role } from '../../../../../../common/model';
import type { KibanaPrivileges } from '../../../model';
import { CollapsiblePanel } from '../../collapsible_panel';
import type { RoleValidator } from '../../validate_role';
import { SimplePrivilegeSection } from './simple_privilege_section';
import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section';
import { TransformErrorSection } from './transform_error_section';
interface Props {
role: Role;
spacesEnabled: boolean;
canCustomizeSubFeaturePrivileges: boolean;
spaces?: Space[];
uiCapabilities: Capabilities;
@ -42,6 +44,7 @@ export class KibanaPrivilegesRegion extends Component<Props, {}> {
const {
kibanaPrivileges,
role,
spacesEnabled,
canCustomizeSubFeaturePrivileges,
spaces = [],
uiCapabilities,
@ -55,17 +58,29 @@ export class KibanaPrivilegesRegion extends Component<Props, {}> {
return <TransformErrorSection />;
}
if (spacesApiUi && spacesEnabled) {
return (
<SpaceAwarePrivilegeSection
kibanaPrivileges={kibanaPrivileges}
role={role}
spaces={spaces}
uiCapabilities={uiCapabilities}
onChange={onChange}
editable={editable}
canCustomizeSubFeaturePrivileges={canCustomizeSubFeaturePrivileges}
validator={validator}
spacesApiUi={spacesApiUi}
/>
);
}
return (
<SpaceAwarePrivilegeSection
<SimplePrivilegeSection
kibanaPrivileges={kibanaPrivileges}
role={role}
spaces={spaces}
uiCapabilities={uiCapabilities}
onChange={onChange}
editable={editable}
canCustomizeSubFeaturePrivileges={canCustomizeSubFeaturePrivileges}
validator={validator}
spacesApiUi={spacesApiUi!}
/>
);
};

View file

@ -0,0 +1,171 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SimplePrivilegeForm> renders without crashing 1`] = `
<Fragment>
<EuiFlexGroup
direction="column"
>
<EuiFlexItem>
<EuiText
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Specifies the Kibana privilege for this role."
id="xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription"
values={Object {}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
display="row"
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Kibana privileges"
id="xpack.security.management.editRole.simplePrivilegeForm.kibanaPrivilegesTitle"
values={Object {}}
/>
}
labelType="label"
>
<EuiSuperSelect
compressed={false}
disabled={false}
fullWidth={false}
hasDividers={true}
isInvalid={false}
isLoading={false}
onChange={[Function]}
options={
Array [
Object {
"dropdownDisplay": <React.Fragment>
<strong>
<FormattedMessage
defaultMessage="None"
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdown"
values={Object {}}
/>
</strong>
<EuiText
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="No access to Kibana"
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdownDescription"
values={Object {}}
/>
</p>
</EuiText>
</React.Fragment>,
"inputDisplay": <FormattedMessage
defaultMessage="None"
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeInput"
values={Object {}}
/>,
"value": "none",
},
Object {
"dropdownDisplay": <React.Fragment>
<strong>
<FormattedMessage
defaultMessage="Read"
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdown"
values={Object {}}
/>
</strong>
<EuiText
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Grants read-only access to all of Kibana"
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdownDescription"
values={Object {}}
/>
</p>
</EuiText>
</React.Fragment>,
"inputDisplay": <FormattedMessage
defaultMessage="Read"
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput"
values={Object {}}
/>,
"value": "read",
},
Object {
"dropdownDisplay": <React.Fragment>
<strong>
<FormattedMessage
defaultMessage="All"
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdown"
values={Object {}}
/>
</strong>
<EuiText
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Grants full access to all of Kibana"
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdownDescription"
values={Object {}}
/>
</p>
</EuiText>
</React.Fragment>,
"inputDisplay": <FormattedMessage
defaultMessage="All"
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeInput"
values={Object {}}
/>,
"value": "all",
},
Object {
"dropdownDisplay": <React.Fragment>
<strong>
<FormattedMessage
defaultMessage="Custom"
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdown"
values={Object {}}
/>
</strong>
<EuiText
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Customize access to Kibana"
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdownDescription"
values={Object {}}
/>
</p>
</EuiText>
</React.Fragment>,
"inputDisplay": <FormattedMessage
defaultMessage="Custom"
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeInput"
values={Object {}}
/>,
"value": "custom",
},
]
}
valueOfSelected="none"
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
`;

View file

@ -0,0 +1,8 @@
/*
* 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 { SimplePrivilegeSection } from './simple_privilege_section';

View file

@ -0,0 +1,59 @@
/*
* 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 { EuiSelect } from '@elastic/eui';
import type { ChangeEvent } from 'react';
import React, { Component } from 'react';
import { NO_PRIVILEGE_VALUE } from '../constants';
interface Props {
['data-test-subj']: string;
availablePrivileges: string[];
onChange: (privilege: string) => void;
value: string | null;
allowNone?: boolean;
disabled?: boolean;
compressed?: boolean;
}
export class PrivilegeSelector extends Component<Props, {}> {
public state = {};
public render() {
const { availablePrivileges, value, disabled, allowNone, compressed } = this.props;
const options = [];
if (allowNone) {
options.push({ value: NO_PRIVILEGE_VALUE, text: 'none' });
}
options.push(
...availablePrivileges.map((p) => ({
value: p,
text: p,
}))
);
return (
<EuiSelect
data-test-subj={this.props['data-test-subj']}
options={options}
hasNoInitialSelection={!allowNone && !value}
value={value || undefined}
onChange={this.onChange}
disabled={disabled}
compressed={compressed}
/>
);
}
public onChange = (e: ChangeEvent<HTMLSelectElement>) => {
this.props.onChange(e.target.value);
};
}

View file

@ -0,0 +1,248 @@
/*
* 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 { EuiButtonGroupProps } from '@elastic/eui';
import { EuiButtonGroup, EuiComboBox, EuiSuperSelect } from '@elastic/eui';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers';
import type { Role } from '../../../../../../../common/model';
import { KibanaPrivileges, SecuredFeature } from '../../../../model';
import { SimplePrivilegeSection } from './simple_privilege_section';
import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning';
const buildProps = (customProps: any = {}) => {
const features = [
new SecuredFeature({
id: 'feature1',
name: 'Feature 1',
app: ['app'],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
app: ['app'],
savedObject: {
all: ['foo'],
read: [],
},
ui: ['app-ui'],
},
read: {
app: ['app'],
savedObject: {
all: [],
read: [],
},
ui: ['app-ui'],
},
},
}),
] as SecuredFeature[];
const kibanaPrivileges = new KibanaPrivileges(
{
features: {
feature1: {
all: ['*'],
read: ['read'],
},
},
global: {},
space: {},
reserved: {},
},
features
);
const role = {
name: '',
elasticsearch: {
cluster: ['manage'],
indices: [],
run_as: [],
},
kibana: [],
...customProps.role,
};
return {
editable: true,
kibanaPrivileges,
features,
onChange: jest.fn(),
canCustomizeSubFeaturePrivileges: true,
...customProps,
role,
};
};
describe('<SimplePrivilegeForm>', () => {
it('renders without crashing', () => {
expect(shallowWithIntl(<SimplePrivilegeSection {...buildProps()} />)).toMatchSnapshot();
});
it('displays "none" when no privilege is selected', () => {
const props = buildProps();
const wrapper = shallowWithIntl(<SimplePrivilegeSection {...props} />);
const selector = wrapper.find(EuiSuperSelect);
expect(selector.props()).toMatchObject({
valueOfSelected: 'none',
});
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(0);
});
it('displays "custom" when feature privileges are customized', () => {
const props = buildProps({
role: {
elasticsearch: {},
kibana: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['foo'],
},
},
],
},
});
const wrapper = shallowWithIntl(<SimplePrivilegeSection {...props} />);
const selector = wrapper.find(EuiSuperSelect);
expect(selector.props()).toMatchObject({
valueOfSelected: 'custom',
});
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(0);
});
it('displays the selected privilege', () => {
const props = buildProps({
role: {
elasticsearch: {},
kibana: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
],
},
});
const wrapper = shallowWithIntl(<SimplePrivilegeSection {...props} />);
const selector = wrapper.find(EuiSuperSelect);
expect(selector.props()).toMatchObject({
valueOfSelected: 'read',
});
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(0);
});
it('displays the reserved privilege', () => {
const props = buildProps({
role: {
elasticsearch: {},
kibana: [
{
spaces: ['*'],
base: [],
feature: {},
_reserved: ['foo'],
},
],
},
});
const wrapper = shallowWithIntl(<SimplePrivilegeSection {...props} />);
const selector = wrapper.find(EuiComboBox);
expect(selector.props()).toMatchObject({
isDisabled: true,
selectedOptions: [{ label: 'foo' }],
});
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(0);
});
it('fires its onChange callback when the privilege changes', () => {
const props = buildProps();
const wrapper = mountWithIntl(<SimplePrivilegeSection {...props} />);
const selector = wrapper.find(EuiSuperSelect);
(selector.props() as any).onChange('all');
expect(props.onChange).toHaveBeenCalledWith({
name: '',
elasticsearch: {
cluster: ['manage'],
indices: [],
run_as: [],
},
kibana: [{ feature: {}, base: ['all'], spaces: ['*'] }],
});
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(0);
});
it('allows feature privileges to be customized', () => {
const props = buildProps({
onChange: (role: Role) => {
wrapper.setProps({
role,
});
},
});
const wrapper = mountWithIntl(<SimplePrivilegeSection {...props} />);
const selector = wrapper.find(EuiSuperSelect);
(selector.props() as any).onChange('custom');
wrapper.update();
const featurePrivilegeToggles = wrapper.find(EuiButtonGroup);
expect(featurePrivilegeToggles).toHaveLength(1);
expect(featurePrivilegeToggles.find('input')).toHaveLength(3);
(featurePrivilegeToggles.props() as EuiButtonGroupProps).onChange('feature1_all', null);
wrapper.update();
expect(wrapper.props().role).toEqual({
elasticsearch: {
cluster: ['manage'],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
feature: {
feature1: ['all'],
},
spaces: ['*'],
},
],
name: '',
});
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(0);
});
it('renders a warning when space privileges are found', () => {
const props = buildProps({
role: {
elasticsearch: {},
kibana: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: ['read'],
feature: {},
},
],
},
});
const wrapper = mountWithIntl(<SimplePrivilegeSection {...props} />);
expect(wrapper.find(UnsupportedSpacePrivilegesWarning)).toHaveLength(1);
});
});

View file

@ -0,0 +1,334 @@
/*
* 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 {
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSuperSelect,
EuiText,
} from '@elastic/eui';
import React, { Component, Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import { copyRole } from '../../../../../../../common/model';
import type { KibanaPrivileges } from '../../../../model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants';
import { FeatureTable } from '../feature_table';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning';
interface Props {
role: Role;
kibanaPrivileges: KibanaPrivileges;
onChange: (role: Role) => void;
editable: boolean;
canCustomizeSubFeaturePrivileges: boolean;
}
interface State {
isCustomizingGlobalPrivilege: boolean;
globalPrivsIndex: number;
}
export class SimplePrivilegeSection extends Component<Props, State> {
constructor(props: Props) {
super(props);
const globalPrivs = this.locateGlobalPrivilege(props.role);
const globalPrivsIndex = this.locateGlobalPrivilegeIndex(props.role);
this.state = {
isCustomizingGlobalPrivilege: Boolean(
globalPrivs && Object.keys(globalPrivs.feature).length > 0
),
globalPrivsIndex,
};
}
public render() {
const kibanaPrivilege = this.getDisplayedBasePrivilege();
const reservedPrivileges = this.props.role.kibana[this.state.globalPrivsIndex]?._reserved ?? [];
const title = (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.kibanaPrivilegesTitle"
defaultMessage="Kibana privileges"
/>
);
const description = (
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription"
defaultMessage="Specifies the Kibana privilege for this role."
/>
</p>
);
return (
<Fragment>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiText size="s" color="subdued">
{description}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label={title}>
{reservedPrivileges.length > 0 ? (
<EuiComboBox
fullWidth
selectedOptions={reservedPrivileges.map((rp) => ({ label: rp }))}
isDisabled
/>
) : (
<EuiSuperSelect
disabled={!this.props.editable}
onChange={this.onKibanaPrivilegeChange}
options={[
{
value: NO_PRIVILEGE_VALUE,
inputDisplay: (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeInput"
defaultMessage="None"
/>
),
dropdownDisplay: (
<>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdown"
defaultMessage="None"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdownDescription"
defaultMessage="No access to Kibana"
/>
</p>
</EuiText>
</>
),
},
{
value: 'read',
inputDisplay: (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput"
defaultMessage="Read"
/>
),
dropdownDisplay: (
<>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdown"
defaultMessage="Read"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdownDescription"
defaultMessage="Grants read-only access to all of Kibana"
/>
</p>
</EuiText>
</>
),
},
{
value: 'all',
inputDisplay: (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeInput"
defaultMessage="All"
/>
),
dropdownDisplay: (
<>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdown"
defaultMessage="All"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdownDescription"
defaultMessage="Grants full access to all of Kibana"
/>
</p>
</EuiText>
</>
),
},
{
value: CUSTOM_PRIVILEGE_VALUE,
inputDisplay: (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeInput"
defaultMessage="Custom"
/>
),
dropdownDisplay: (
<>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdown"
defaultMessage="Custom"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdownDescription"
defaultMessage="Customize access to Kibana"
/>
</p>
</EuiText>
</>
),
},
]}
hasDividers
valueOfSelected={kibanaPrivilege}
/>
)}
</EuiFormRow>
{this.state.isCustomizingGlobalPrivilege && (
<EuiFormRow fullWidth>
<FeatureTable
role={this.props.role}
kibanaPrivileges={this.props.kibanaPrivileges}
privilegeCalculator={
new PrivilegeFormCalculator(this.props.kibanaPrivileges, this.props.role)
}
onChange={this.onFeaturePrivilegeChange}
onChangeAll={this.onChangeAllFeaturePrivileges}
privilegeIndex={this.props.role.kibana.findIndex((k) =>
isGlobalPrivilegeDefinition(k)
)}
canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges}
allSpacesSelected
disabled={!this.props.editable}
/>
</EuiFormRow>
)}
{this.maybeRenderSpacePrivilegeWarning()}
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
public getDisplayedBasePrivilege = () => {
if (this.state.isCustomizingGlobalPrivilege) {
return CUSTOM_PRIVILEGE_VALUE;
}
const { role } = this.props;
const form = this.locateGlobalPrivilege(role);
return form && form.base.length > 0 ? form.base[0] : NO_PRIVILEGE_VALUE;
};
public onKibanaPrivilegeChange = (privilege: string) => {
const role = copyRole(this.props.role);
const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role);
if (privilege === NO_PRIVILEGE_VALUE) {
// Remove global entry if no privilege value
role.kibana = role.kibana.filter((entry) => !isGlobalPrivilegeDefinition(entry));
} else if (privilege === CUSTOM_PRIVILEGE_VALUE) {
// Remove base privilege if customizing feature privileges
form.base = [];
} else {
form.base = [privilege];
form.feature = {};
}
this.props.onChange(role);
this.setState({
isCustomizingGlobalPrivilege: privilege === CUSTOM_PRIVILEGE_VALUE,
globalPrivsIndex: role.kibana.indexOf(form),
});
};
public onFeaturePrivilegeChange = (featureId: string, privileges: string[]) => {
const role = copyRole(this.props.role);
const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role);
if (privileges.length > 0) {
form.feature[featureId] = [...privileges];
} else {
delete form.feature[featureId];
}
this.props.onChange(role);
};
private onChangeAllFeaturePrivileges = (privileges: string[]) => {
const role = copyRole(this.props.role);
const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role);
if (privileges.length > 0) {
this.props.kibanaPrivileges.getSecuredFeatures().forEach((feature) => {
form.feature[feature.id] = [...privileges];
});
} else {
form.feature = {};
}
this.props.onChange(role);
};
private maybeRenderSpacePrivilegeWarning = () => {
const kibanaPrivileges = this.props.role.kibana;
const hasSpacePrivileges = kibanaPrivileges.some(
(privilege) => !isGlobalPrivilegeDefinition(privilege)
);
if (hasSpacePrivileges) {
return (
<EuiFormRow fullWidth>
<UnsupportedSpacePrivilegesWarning />
</EuiFormRow>
);
}
return null;
};
private locateGlobalPrivilegeIndex = (role: Role) => {
return role.kibana.findIndex((privileges) => isGlobalPrivilegeDefinition(privileges));
};
private locateGlobalPrivilege = (role: Role) => {
const spacePrivileges = role.kibana;
return spacePrivileges.find((privileges) => isGlobalPrivilegeDefinition(privileges));
};
private createGlobalPrivilegeEntry(role: Role): RoleKibanaPrivilege {
const newEntry = {
spaces: ['*'],
base: [],
feature: {},
};
role.kibana.push(newEntry);
return newEntry;
}
}

View file

@ -0,0 +1,26 @@
/*
* 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 { EuiCallOut } from '@elastic/eui';
import React, { Component } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
export class UnsupportedSpacePrivilegesWarning extends Component<{}, {}> {
public render() {
return <EuiCallOut iconType="alert" color="warning" title={this.getMessage()} />;
}
private getMessage = () => {
return (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning"
defaultMessage="This role contains privilege definitions for spaces, but spaces are not enabled in Kibana. Saving this role will remove these privileges."
/>
);
};
}

View file

@ -78,7 +78,7 @@ export class SpaceAwarePrivilegeSection extends Component<Props, State> {
public render() {
const { uiCapabilities } = this.props;
if (!uiCapabilities.spaces.manage) {
if (!uiCapabilities.spaces?.manage) {
return (
<EuiCallOut
title={

View file

@ -6,7 +6,12 @@
*/
export { isReservedSpace } from './is_reserved_space';
export { MAX_SPACE_INITIALS, SPACE_SEARCH_COUNT_THRESHOLD, ENTER_SPACE_PATH } from './constants';
export {
MAX_SPACE_INITIALS,
SPACE_SEARCH_COUNT_THRESHOLD,
ENTER_SPACE_PATH,
DEFAULT_SPACE_ID,
} from './constants';
export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser';
export type {
Space,

View file

@ -0,0 +1,56 @@
/*
* 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.
*/
jest.mock('crypto', () => ({
randomBytes: jest.fn(),
constants: jest.requireActual('crypto').constants,
}));
jest.mock('@kbn/utils', () => ({
getLogsPath: () => '/mock/kibana/logs/path',
}));
import { ConfigSchema } from './config';
describe('config schema', () => {
it('generates proper defaults', () => {
expect(ConfigSchema.validate({})).toMatchInlineSnapshot(`
Object {
"enabled": true,
"maxSpaces": 1000,
}
`);
expect(ConfigSchema.validate({}, { dev: false })).toMatchInlineSnapshot(`
Object {
"enabled": true,
"maxSpaces": 1000,
}
`);
expect(ConfigSchema.validate({}, { dev: true })).toMatchInlineSnapshot(`
Object {
"enabled": true,
"maxSpaces": 1000,
}
`);
});
it('should throw error if spaces is disabled', () => {
expect(() => ConfigSchema.validate({ enabled: false })).toThrow(
'[enabled]: Spaces can only be disabled in development mode'
);
expect(() => ConfigSchema.validate({ enabled: false }, { dev: false })).toThrow(
'[enabled]: Spaces can only be disabled in development mode'
);
});
it('should not throw error if spaces is disabled in development mode', () => {
expect(() => ConfigSchema.validate({ enabled: false }, { dev: true })).not.toThrow();
});
});

View file

@ -12,6 +12,19 @@ import { schema } from '@kbn/config-schema';
import type { PluginInitializerContext } from '@kbn/core/server';
export const ConfigSchema = schema.object({
enabled: schema.conditional(
schema.contextRef('dev'),
true,
schema.boolean({ defaultValue: true }),
schema.boolean({
validate: (rawValue) => {
if (rawValue === false) {
return 'Spaces can only be disabled in development mode';
}
},
defaultValue: true,
})
),
maxSpaces: schema.number({ defaultValue: 1000 }),
});

View file

@ -16,7 +16,7 @@ const createMockDebugLogger = () => {
return jest.fn();
};
const createMockConfig = (mockConfig: ConfigType = { maxSpaces: 1000 }) => {
const createMockConfig = (mockConfig: ConfigType = { enabled: true, maxSpaces: 1000 }) => {
return ConfigSchema.validate(mockConfig);
};
@ -75,7 +75,7 @@ describe('#getAll', () => {
mockCallWithRequestRepository.find.mockResolvedValue({
saved_objects: savedObjects,
} as any);
const mockConfig = createMockConfig({ maxSpaces: 1234 });
const mockConfig = createMockConfig({ enabled: true, maxSpaces: 1234 });
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
const actualSpaces = await client.getAll();
@ -182,7 +182,7 @@ describe('#create', () => {
total: maxSpaces - 1,
} as any);
const mockConfig = createMockConfig({ maxSpaces });
const mockConfig = createMockConfig({ enabled: true, maxSpaces });
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
@ -208,7 +208,7 @@ describe('#create', () => {
total: maxSpaces,
} as any);
const mockConfig = createMockConfig({ maxSpaces });
const mockConfig = createMockConfig({ enabled: true, maxSpaces });
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);