Show sub-feature privileges when using the Basic license (#142020)

* Augments /api/security/privileges with optional respectLicenseLevel parameter for use by the edit_role_page.
Implements fix for 125289 - Show sub-feature privileges when using the Basic license

* Changed EuiTooltip to EuiIconTip.

* Updated unit tests for feature table expanded row to include new property checks.

* Renamed property to improve readability and reduce confusion. Fixed state of switch checked in sub-feature customization.

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* Fixed privilege get API default for 'respectLicenseLevel'. Updated privilege unit tests.

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Uodated test description to match property name.

* Updated privilege API integration tests to include new 'respectLicenseLevel' optional parameter.

* Replaced empty fragment with undefined.

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Thom Heymann <190132+thomheymann@users.noreply.github.com>
This commit is contained in:
Jeramy Soucy 2022-10-11 09:33:58 -04:00 committed by GitHub
parent b6885f21c6
commit fb632caa33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 638 additions and 100 deletions

View file

@ -141,7 +141,7 @@ function usePrivileges(
); );
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
privilegesAPIClient.getAll({ includeActions: true }), privilegesAPIClient.getAll({ includeActions: true, respectLicenseLevel: false }),
privilegesAPIClient.getBuiltIn(), privilegesAPIClient.getBuiltIn(),
]).then( ]).then(
([kibanaPrivileges, builtInESPrivileges]) => ([kibanaPrivileges, builtInESPrivileges]) =>

View file

@ -7,10 +7,11 @@
import './feature_table.scss'; import './feature_table.scss';
import type { EuiAccordionProps, EuiButtonGroupOptionProps } from '@elastic/eui';
import { import {
EuiAccordion, EuiAccordion,
EuiAccordionProps,
EuiButtonGroup, EuiButtonGroup,
EuiButtonGroupOptionProps,
EuiCallOut, EuiCallOut,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
@ -216,7 +217,6 @@ export class FeatureTable extends Component<Props, State> {
extraAction: EuiAccordionProps['extraAction'], extraAction: EuiAccordionProps['extraAction'],
infoIcon: JSX.Element infoIcon: JSX.Element
) => { ) => {
const { canCustomizeSubFeaturePrivileges } = this.props;
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
return ( return (
@ -228,12 +228,8 @@ export class FeatureTable extends Component<Props, State> {
data-test-subj="featurePrivilegeControls" data-test-subj="featurePrivilegeControls"
buttonContent={buttonContent} buttonContent={buttonContent}
extraAction={extraAction} extraAction={extraAction}
forceState={ forceState={hasSubFeaturePrivileges ? undefined : 'closed'}
canCustomizeSubFeaturePrivileges && hasSubFeaturePrivileges ? undefined : 'closed' arrowDisplay={hasSubFeaturePrivileges ? 'left' : 'none'}
}
arrowDisplay={
canCustomizeSubFeaturePrivileges && hasSubFeaturePrivileges ? 'left' : 'none'
}
onToggle={(isOpen: boolean) => { onToggle={(isOpen: boolean) => {
if (isOpen) { if (isOpen) {
this.state.expandedPrivilegeControls.add(feature.id); this.state.expandedPrivilegeControls.add(feature.id);
@ -256,6 +252,9 @@ export class FeatureTable extends Component<Props, State> {
this.props.role.kibana[this.props.privilegeIndex].feature[feature.id] ?? [] this.props.role.kibana[this.props.privilegeIndex].feature[feature.id] ?? []
} }
disabled={this.props.disabled} disabled={this.props.disabled}
licenseAllowsSubFeatPrivCustomization={
this.props.canCustomizeSubFeaturePrivileges
}
/> />
</div> </div>
</EuiAccordion> </EuiAccordion>
@ -335,14 +334,10 @@ export class FeatureTable extends Component<Props, State> {
); );
} }
const { canCustomizeSubFeaturePrivileges } = this.props;
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
const showAccordionArrow = canCustomizeSubFeaturePrivileges && hasSubFeaturePrivileges;
const buttonContent = ( const buttonContent = (
<> <>
{!showAccordionArrow && <EuiIcon type="empty" size="l" />}{' '} {!hasSubFeaturePrivileges && <EuiIcon type="empty" size="l" />}{' '}
<FeatureTableCell feature={feature} /> <FeatureTableCell feature={feature} />
</> </>
); );

View file

@ -25,6 +25,75 @@ const createRole = (kibana: Role['kibana'] = []): Role => {
}; };
describe('FeatureTableExpandedRow', () => { describe('FeatureTableExpandedRow', () => {
it('indicates sub-feature privileges are not customizable when licenseAllowsSubFeatPrivCustomization is false', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['foo'],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const wrapper = mountWithIntl(
<FeatureTableExpandedRow
feature={feature}
privilegeIndex={0}
privilegeCalculator={calculator}
selectedFeaturePrivileges={['minimal_read']}
onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={false}
/>
);
expect(
wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props()
).toMatchObject({
disabled: true,
checked: false,
});
expect(wrapper.find('EuiIconTip[data-test-subj="subFeaturesTip"]').length).toBe(1);
});
it('indicates sub-feature privileges can be customized when licenseAllowsSubFeatPrivCustomization is true', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['foo'],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const wrapper = mountWithIntl(
<FeatureTableExpandedRow
feature={feature}
privilegeIndex={0}
privilegeCalculator={calculator}
selectedFeaturePrivileges={['none']}
onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/>
);
expect(
wrapper.find('EuiIconTip[data-test-subj="cannotCustomizeSubFeaturesTooltip"]').length
).toBe(0);
});
it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => { it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => {
const role = createRole([ const role = createRole([
{ {
@ -48,6 +117,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator} privilegeCalculator={calculator}
selectedFeaturePrivileges={['minimal_read']} selectedFeaturePrivileges={['minimal_read']}
onChange={jest.fn()} onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/> />
); );
@ -82,6 +152,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator} privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']} selectedFeaturePrivileges={['read']}
onChange={jest.fn()} onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/> />
); );
@ -114,6 +185,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator} privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']} selectedFeaturePrivileges={['read']}
onChange={jest.fn()} onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/> />
); );
@ -150,6 +222,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator} privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']} selectedFeaturePrivileges={['read']}
onChange={onChange} onChange={onChange}
licenseAllowsSubFeatPrivCustomization={true}
/> />
); );
@ -189,6 +262,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator} privilegeCalculator={calculator}
selectedFeaturePrivileges={['minimal_read', 'cool_read', 'cool_toggle_2']} selectedFeaturePrivileges={['minimal_read', 'cool_read', 'cool_toggle_2']}
onChange={onChange} onChange={onChange}
licenseAllowsSubFeatPrivCustomization={true}
/> />
); );

View file

@ -6,9 +6,10 @@
*/ */
import type { EuiSwitchEvent } from '@elastic/eui'; import type { EuiSwitchEvent } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSwitch } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSwitch } from '@elastic/eui';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import type { SecuredFeature } from '../../../../model'; import type { SecuredFeature } from '../../../../model';
@ -21,6 +22,7 @@ interface Props {
privilegeIndex: number; privilegeIndex: number;
selectedFeaturePrivileges: string[]; selectedFeaturePrivileges: string[];
disabled?: boolean; disabled?: boolean;
licenseAllowsSubFeatPrivCustomization: boolean;
onChange: (featureId: string, featurePrivileges: string[]) => void; onChange: (featureId: string, featurePrivileges: string[]) => void;
} }
@ -31,11 +33,13 @@ export const FeatureTableExpandedRow = ({
privilegeCalculator, privilegeCalculator,
selectedFeaturePrivileges, selectedFeaturePrivileges,
disabled, disabled,
licenseAllowsSubFeatPrivCustomization,
}: Props) => { }: Props) => {
const [isCustomizing, setIsCustomizing] = useState(() => { const [isCustomizing, setIsCustomizing] = useState(() => {
return feature return (
.getMinimalFeaturePrivileges() licenseAllowsSubFeatPrivCustomization &&
.some((p) => selectedFeaturePrivileges.includes(p.id)); feature.getMinimalFeaturePrivileges().some((p) => selectedFeaturePrivileges.includes(p.id))
);
}); });
useEffect(() => { useEffect(() => {
@ -43,10 +47,13 @@ export const FeatureTableExpandedRow = ({
.getMinimalFeaturePrivileges() .getMinimalFeaturePrivileges()
.some((p) => selectedFeaturePrivileges.includes(p.id)); .some((p) => selectedFeaturePrivileges.includes(p.id));
if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) { if (
(!licenseAllowsSubFeatPrivCustomization || !hasMinimalFeaturePrivilegeSelected) &&
isCustomizing
) {
setIsCustomizing(false); setIsCustomizing(false);
} }
}, [feature, isCustomizing, selectedFeaturePrivileges]); }, [feature, isCustomizing, selectedFeaturePrivileges, licenseAllowsSubFeatPrivCustomization]);
const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => { const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => {
onChange( onChange(
@ -63,6 +70,7 @@ export const FeatureTableExpandedRow = ({
return ( return (
<EuiFlexGroup direction="column"> <EuiFlexGroup direction="column">
<EuiFlexItem> <EuiFlexItem>
<div>
<EuiSwitch <EuiSwitch
label={ label={
<FormattedMessage <FormattedMessage
@ -75,9 +83,31 @@ export const FeatureTableExpandedRow = ({
data-test-subj="customizeSubFeaturePrivileges" data-test-subj="customizeSubFeaturePrivileges"
disabled={ disabled={
disabled || disabled ||
!licenseAllowsSubFeatPrivCustomization ||
!privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex) !privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex)
} }
/> />
{licenseAllowsSubFeatPrivCustomization ? undefined : (
<EuiIconTip
data-test-subj="subFeaturesTip"
position="right"
aria-label="sub-feature-information-tip"
size="m"
type="iInCircle"
color="subdued"
iconProps={{
className: 'eui-alignTop',
}}
content={i18n.translate(
'xpack.security.management.editRole.featureTable.cannotCustomizeSubFeaturesTooltip',
{
defaultMessage:
'Customization of sub-feature privileges is a subscription feature.',
}
)}
/>
)}
</div>
</EuiFlexItem> </EuiFlexItem>
{feature.getSubFeatures().map((subFeature) => { {feature.getSubFeatures().map((subFeature) => {
return ( return (

View file

@ -547,6 +547,7 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
} }
}); });
} }
this.setState({ this.setState({
role, role,
privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role),

View file

@ -12,9 +12,19 @@ import type { BuiltinESPrivileges, RawKibanaPrivileges } from '../../../common/m
export class PrivilegesAPIClient { export class PrivilegesAPIClient {
constructor(private readonly http: HttpStart) {} constructor(private readonly http: HttpStart) {}
async getAll({ includeActions }: { includeActions: boolean }) { /*
* respectLicenseLevel is an internal optional parameter soley for getting all sub-feature
* privilieges to use in the UI. It is not meant for any other use.
*/
async getAll({
includeActions,
respectLicenseLevel = true,
}: {
includeActions: boolean;
respectLicenseLevel: boolean;
}) {
return await this.http.get<RawKibanaPrivileges>('/api/security/privileges', { return await this.http.get<RawKibanaPrivileges>('/api/security/privileges', {
query: { includeActions }, query: { includeActions, respectLicenseLevel },
}); });
} }

View file

@ -1888,6 +1888,225 @@ describe('subFeatures', () => {
actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'sub-feature-ui'),
]); ]);
}); });
test(`should get the sub-feature privileges if 'respectLicenseLevel' is false`, () => {
const features: KibanaFeature[] = [
new KibanaFeature({
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
savedObject: {
all: [],
read: [],
},
ui: ['foo'],
},
read: {
savedObject: {
all: [],
read: [],
},
ui: ['foo'],
},
},
subFeatures: [
{
name: 'subFeature1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'subFeaturePriv1',
name: 'sub feature priv 1',
includeIn: 'read',
savedObject: {
all: ['all-sub-feature-type'],
read: ['read-sub-feature-type'],
},
ui: ['sub-feature-ui'],
},
],
},
],
},
],
}),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
const actual = privileges.get(false);
expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`);
expect(actual.features).toHaveProperty(`foo.all`, [
actions.login,
actions.version,
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-sub-feature-type', 'get'),
actions.savedObject.get('all-sub-feature-type', 'find'),
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'create'),
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
actions.savedObject.get('read-sub-feature-type', 'find'),
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
actions.ui.get('foo', 'foo'),
actions.ui.get('foo', 'sub-feature-ui'),
]);
expect(actual.features).toHaveProperty(`foo.minimal_all`, [
actions.login,
actions.version,
actions.ui.get('foo', 'foo'),
]);
expect(actual.features).toHaveProperty(`foo.read`, [
actions.login,
actions.version,
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-sub-feature-type', 'get'),
actions.savedObject.get('all-sub-feature-type', 'find'),
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'create'),
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
actions.savedObject.get('read-sub-feature-type', 'find'),
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
actions.ui.get('foo', 'foo'),
actions.ui.get('foo', 'sub-feature-ui'),
]);
expect(actual.features).toHaveProperty(`foo.minimal_read`, [
actions.login,
actions.version,
actions.ui.get('foo', 'foo'),
]);
expect(actual).toHaveProperty('global.all', [
actions.login,
actions.version,
actions.api.get('decryptedTelemetry'),
actions.api.get('features'),
actions.api.get('taskManager'),
actions.space.manage,
actions.ui.get('spaces', 'manage'),
actions.ui.get('management', 'kibana', 'spaces'),
actions.ui.get('catalogue', 'spaces'),
actions.ui.get('enterpriseSearch', 'all'),
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-sub-feature-type', 'get'),
actions.savedObject.get('all-sub-feature-type', 'find'),
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'create'),
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
actions.savedObject.get('read-sub-feature-type', 'find'),
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
actions.ui.get('foo', 'foo'),
actions.ui.get('foo', 'sub-feature-ui'),
]);
expect(actual).toHaveProperty('global.read', [
actions.login,
actions.version,
actions.api.get('decryptedTelemetry'),
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-sub-feature-type', 'get'),
actions.savedObject.get('all-sub-feature-type', 'find'),
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'create'),
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
actions.savedObject.get('read-sub-feature-type', 'find'),
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
actions.ui.get('foo', 'foo'),
actions.ui.get('foo', 'sub-feature-ui'),
]);
expect(actual).toHaveProperty('space.all', [
actions.login,
actions.version,
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-sub-feature-type', 'get'),
actions.savedObject.get('all-sub-feature-type', 'find'),
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'create'),
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
actions.savedObject.get('read-sub-feature-type', 'find'),
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
actions.ui.get('foo', 'foo'),
actions.ui.get('foo', 'sub-feature-ui'),
]);
expect(actual).toHaveProperty('space.read', [
actions.login,
actions.version,
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-sub-feature-type', 'get'),
actions.savedObject.get('all-sub-feature-type', 'find'),
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
actions.savedObject.get('all-sub-feature-type', 'create'),
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
actions.savedObject.get('read-sub-feature-type', 'find'),
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
actions.ui.get('foo', 'foo'),
actions.ui.get('foo', 'sub-feature-ui'),
]);
});
}); });
describe(`when license allows subfeatures, but not a specific sub feature`, () => { describe(`when license allows subfeatures, but not a specific sub feature`, () => {

View file

@ -18,7 +18,7 @@ import type { Actions } from '../actions';
import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder';
export interface PrivilegesService { export interface PrivilegesService {
get(): RawKibanaPrivileges; get(respectLicenseLevel?: boolean): RawKibanaPrivileges;
} }
export function privilegesFactory( export function privilegesFactory(
@ -29,7 +29,7 @@ export function privilegesFactory(
const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions);
return { return {
get() { get(respectLicenseLevel: boolean = true) {
const features = featuresService.getKibanaFeatures(); const features = featuresService.getKibanaFeatures();
const { allowSubFeaturePrivileges } = licenseService.getFeatures(); const { allowSubFeaturePrivileges } = licenseService.getFeatures();
const { hasAtLeast: licenseHasAtLeast } = licenseService; const { hasAtLeast: licenseHasAtLeast } = licenseService;
@ -82,7 +82,10 @@ export function privilegesFactory(
]; ];
} }
if (allowSubFeaturePrivileges && feature.subFeatures?.length > 0) { if (
(!respectLicenseLevel || allowSubFeaturePrivileges) &&
feature.subFeatures?.length > 0
) {
for (const subFeaturePrivilege of featuresService.subFeaturePrivilegeIterator( for (const subFeaturePrivilege of featuresService.subFeaturePrivilegeIterator(
feature, feature,
licenseHasAtLeast licenseHasAtLeast

View file

@ -21,11 +21,15 @@ export function defineGetPrivilegesRoutes({ router, authz }: RouteDefinitionPara
includeActions: schema.maybe( includeActions: schema.maybe(
schema.oneOf([schema.literal('true'), schema.literal('false')]) schema.oneOf([schema.literal('true'), schema.literal('false')])
), ),
respectLicenseLevel: schema.maybe(
schema.oneOf([schema.literal('true'), schema.literal('false')])
),
}), }),
}, },
}, },
createLicensedRouteHandler((context, request, response) => { createLicensedRouteHandler((context, request, response) => {
const privileges = authz.privileges.get(); const respectLicenseLevel = request.query.respectLicenseLevel !== 'false'; // if undefined resolve to true by default
const privileges = authz.privileges.get(respectLicenseLevel);
const includeActions = request.query.includeActions === 'true'; const includeActions = request.query.includeActions === 'true';
const privilegesResponseBody = includeActions const privilegesResponseBody = includeActions
? privileges ? privileges

View file

@ -14,13 +14,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) { export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest'); const supertest = getService('supertest');
describe('Privileges', () => { const expectedWithoutActions = {
describe('GET /api/security/privileges', () => {
it('should return a privilege map with all known privileges, without actions', async () => {
// If you're adding a privilege to the following, that's great!
// If you're removing a privilege, this breaks backwards compatibility
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
const expected = {
global: ['all', 'read'], global: ['all', 'read'],
space: ['all', 'read'], space: ['all', 'read'],
features: { features: {
@ -79,6 +73,13 @@ export default function ({ getService }: FtrProviderContext) {
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
}; };
describe('Privileges', () => {
describe('GET /api/security/privileges', () => {
it('should return a privilege map with all known privileges, without actions', async () => {
// If you're adding a privilege to the following, that's great!
// If you're removing a privilege, this breaks backwards compatibility
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
await supertest await supertest
.get('/api/security/privileges') .get('/api/security/privileges')
.set('kbn-xsrf', 'xxx') .set('kbn-xsrf', 'xxx')
@ -89,7 +90,7 @@ export default function ({ getService }: FtrProviderContext) {
// supertest uses assert.deepStrictEqual. // supertest uses assert.deepStrictEqual.
// expect.js doesn't help us here. // expect.js doesn't help us here.
// and lodash's isEqual doesn't know how to compare Sets. // and lodash's isEqual doesn't know how to compare Sets.
const success = isEqualWith(res.body, expected, (value, other, key) => { const success = isEqualWith(res.body, expectedWithoutActions, (value, other, key) => {
if (Array.isArray(value) && Array.isArray(other)) { if (Array.isArray(value) && Array.isArray(other)) {
if (key === 'reserved') { if (key === 'reserved') {
// order does not matter for the reserved privilege set. // order does not matter for the reserved privilege set.
@ -106,7 +107,9 @@ export default function ({ getService }: FtrProviderContext) {
if (!success) { if (!success) {
throw new Error( throw new Error(
`Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}` `Expected ${util.inspect(res.body)} to equal ${util.inspect(
expectedWithoutActions
)}`
); );
} }
}) })
@ -178,5 +181,115 @@ export default function ({ getService }: FtrProviderContext) {
}); });
}); });
}); });
// In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag
describe('GET /api/security/privileges?respectLicenseLevel=false', () => {
it('should return a privilege map with all known privileges, without actions', async () => {
// If you're adding a privilege to the following, that's great!
// If you're removing a privilege, this breaks backwards compatibility
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
await supertest
.get('/api/security/privileges?respectLicenseLevel=false')
.set('kbn-xsrf', 'xxx')
.send()
.expect(200)
.expect((res: any) => {
// when comparing privileges, the order of the features doesn't matter (but the order of the privileges does)
// supertest uses assert.deepStrictEqual.
// expect.js doesn't help us here.
// and lodash's isEqual doesn't know how to compare Sets.
const success = isEqualWith(res.body, expectedWithoutActions, (value, other, key) => {
if (Array.isArray(value) && Array.isArray(other)) {
if (key === 'reserved') {
// order does not matter for the reserved privilege set.
return isEqual(value.sort(), other.sort());
}
// order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness.
return isEqual(value, other);
}
// Lodash types aren't correct, `undefined` should be supported as a return value here and it
// has special meaning.
return undefined as any;
});
if (!success) {
throw new Error(
`Expected ${util.inspect(res.body)} to equal ${util.inspect(
expectedWithoutActions
)}`
);
}
})
.expect(200);
});
});
// In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag
describe('GET /api/security/privileges?includeActions=true&respectLicenseLevel=false', () => {
// The UI assumes that no wildcards are present when calculating the effective set of privileges.
// If this changes, then the "privilege calculators" will need revisiting to account for these wildcards.
it('should return a privilege map with actions which do not include wildcards', async () => {
await supertest
.get('/api/security/privileges?includeActions=true')
.set('kbn-xsrf', 'xxx')
.send()
.expect(200)
.expect((res: any) => {
const { features, global, space, reserved } = res.body as RawKibanaPrivileges;
expect(features).to.be.an('object');
expect(global).to.be.an('object');
expect(space).to.be.an('object');
expect(reserved).to.be.an('object');
Object.entries(features).forEach(([featureId, featurePrivs]) => {
Object.values(featurePrivs).forEach((actions) => {
expect(actions).to.be.an('array');
actions.forEach((action) => {
expect(action).to.be.a('string');
expect(action.indexOf('*')).to.eql(
-1,
`Feature ${featureId} with action ${action} cannot contain a wildcard`
);
});
});
});
Object.entries(global).forEach(([privilegeId, actions]) => {
expect(actions).to.be.an('array');
actions.forEach((action) => {
expect(action).to.be.a('string');
expect(action.indexOf('*')).to.eql(
-1,
`Global privilege ${privilegeId} with action ${action} cannot contain a wildcard`
);
});
});
Object.entries(space).forEach(([privilegeId, actions]) => {
expect(actions).to.be.an('array');
actions.forEach((action) => {
expect(action).to.be.a('string');
expect(action.indexOf('*')).to.eql(
-1,
`Space privilege ${privilegeId} with action ${action} cannot contain a wildcard`
);
});
});
Object.entries(reserved).forEach(([privilegeId, actions]) => {
expect(actions).to.be.an('array');
actions.forEach((action) => {
expect(action).to.be.a('string');
expect(action.indexOf('*')).to.eql(
-1,
`Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard`
);
});
});
});
});
});
}); });
} }

View file

@ -79,6 +79,95 @@ export default function ({ getService }: FtrProviderContext) {
}) })
.expect(200); .expect(200);
}); });
it('should include sub-feature privileges when respectlicenseLevel is false', async () => {
const expected = {
global: ['all', 'read'],
space: ['all', 'read'],
features: {
graph: ['all', 'read', 'minimal_all', 'minimal_read'],
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
canvas: ['all', 'read', 'minimal_all', 'minimal_read'],
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
actions: ['all', 'read', 'minimal_all', 'minimal_read'],
stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'],
ml: ['all', 'read', 'minimal_all', 'minimal_read'],
siem: ['all', 'read', 'minimal_all', 'minimal_read'],
uptime: ['all', 'read', 'minimal_all', 'minimal_read'],
securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
apm: ['all', 'read', 'minimal_all', 'minimal_read'],
discover: [
'all',
'read',
'minimal_all',
'minimal_read',
'url_create',
'store_search_session',
],
visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'],
dashboard: [
'all',
'read',
'minimal_all',
'minimal_read',
'url_create',
'store_search_session',
],
dev_tools: ['all', 'read', 'minimal_all', 'minimal_read'],
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
osquery: [
'all',
'read',
'minimal_all',
'minimal_read',
'live_queries_all',
'live_queries_read',
'run_saved_queries',
'saved_queries_all',
'saved_queries_read',
'packs_all',
'packs_read',
],
},
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};
await supertest
.get('/api/security/privileges?respectLicenseLevel=false')
.set('kbn-xsrf', 'xxx')
.send()
.expect(200)
.expect((res: any) => {
// when comparing privileges, the order of the privileges doesn't matter.
// supertest uses assert.deepStrictEqual.
// expect.js doesn't help us here.
// and lodash's isEqual doesn't know how to compare Sets.
const success = isEqualWith(res.body, expected, (value, other, key) => {
if (Array.isArray(value) && Array.isArray(other)) {
return isEqual(value.sort(), other.sort());
}
// Lodash types aren't correct, `undefined` should be supported as a return value here and it
// has special meaning.
return undefined as any;
});
if (!success) {
throw new Error(
`Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}`
);
}
})
.expect(200);
});
}); });
}); });
} }