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(() => {
Promise.all([
privilegesAPIClient.getAll({ includeActions: true }),
privilegesAPIClient.getAll({ includeActions: true, respectLicenseLevel: false }),
privilegesAPIClient.getBuiltIn(),
]).then(
([kibanaPrivileges, builtInESPrivileges]) =>

View file

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

View file

@ -25,6 +25,75 @@ const createRole = (kibana: Role['kibana'] = []): Role => {
};
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', () => {
const role = createRole([
{
@ -48,6 +117,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator}
selectedFeaturePrivileges={['minimal_read']}
onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/>
);
@ -82,6 +152,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']}
onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/>
);
@ -114,6 +185,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']}
onChange={jest.fn()}
licenseAllowsSubFeatPrivCustomization={true}
/>
);
@ -150,6 +222,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']}
onChange={onChange}
licenseAllowsSubFeatPrivCustomization={true}
/>
);
@ -189,6 +262,7 @@ describe('FeatureTableExpandedRow', () => {
privilegeCalculator={calculator}
selectedFeaturePrivileges={['minimal_read', 'cool_read', 'cool_toggle_2']}
onChange={onChange}
licenseAllowsSubFeatPrivCustomization={true}
/>
);

View file

@ -6,9 +6,10 @@
*/
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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { SecuredFeature } from '../../../../model';
@ -21,6 +22,7 @@ interface Props {
privilegeIndex: number;
selectedFeaturePrivileges: string[];
disabled?: boolean;
licenseAllowsSubFeatPrivCustomization: boolean;
onChange: (featureId: string, featurePrivileges: string[]) => void;
}
@ -31,11 +33,13 @@ export const FeatureTableExpandedRow = ({
privilegeCalculator,
selectedFeaturePrivileges,
disabled,
licenseAllowsSubFeatPrivCustomization,
}: Props) => {
const [isCustomizing, setIsCustomizing] = useState(() => {
return feature
.getMinimalFeaturePrivileges()
.some((p) => selectedFeaturePrivileges.includes(p.id));
return (
licenseAllowsSubFeatPrivCustomization &&
feature.getMinimalFeaturePrivileges().some((p) => selectedFeaturePrivileges.includes(p.id))
);
});
useEffect(() => {
@ -43,10 +47,13 @@ export const FeatureTableExpandedRow = ({
.getMinimalFeaturePrivileges()
.some((p) => selectedFeaturePrivileges.includes(p.id));
if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) {
if (
(!licenseAllowsSubFeatPrivCustomization || !hasMinimalFeaturePrivilegeSelected) &&
isCustomizing
) {
setIsCustomizing(false);
}
}, [feature, isCustomizing, selectedFeaturePrivileges]);
}, [feature, isCustomizing, selectedFeaturePrivileges, licenseAllowsSubFeatPrivCustomization]);
const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => {
onChange(
@ -63,21 +70,44 @@ export const FeatureTableExpandedRow = ({
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiSwitch
label={
<FormattedMessage
id="xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel"
defaultMessage="Customize sub-feature privileges"
<div>
<EuiSwitch
label={
<FormattedMessage
id="xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel"
defaultMessage="Customize sub-feature privileges"
/>
}
checked={isCustomizing}
onChange={onCustomizeSubFeatureChange}
data-test-subj="customizeSubFeaturePrivileges"
disabled={
disabled ||
!licenseAllowsSubFeatPrivCustomization ||
!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.',
}
)}
/>
}
checked={isCustomizing}
onChange={onCustomizeSubFeatureChange}
data-test-subj="customizeSubFeaturePrivileges"
disabled={
disabled ||
!privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex)
}
/>
)}
</div>
</EuiFlexItem>
{feature.getSubFeatures().map((subFeature) => {
return (

View file

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

View file

@ -12,9 +12,19 @@ import type { BuiltinESPrivileges, RawKibanaPrivileges } from '../../../common/m
export class PrivilegesAPIClient {
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', {
query: { includeActions },
query: { includeActions, respectLicenseLevel },
});
}

View file

@ -1888,6 +1888,225 @@ describe('subFeatures', () => {
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`, () => {

View file

@ -18,7 +18,7 @@ import type { Actions } from '../actions';
import { featurePrivilegeBuilderFactory } from './feature_privilege_builder';
export interface PrivilegesService {
get(): RawKibanaPrivileges;
get(respectLicenseLevel?: boolean): RawKibanaPrivileges;
}
export function privilegesFactory(
@ -29,7 +29,7 @@ export function privilegesFactory(
const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions);
return {
get() {
get(respectLicenseLevel: boolean = true) {
const features = featuresService.getKibanaFeatures();
const { allowSubFeaturePrivileges } = licenseService.getFeatures();
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(
feature,
licenseHasAtLeast

View file

@ -21,11 +21,15 @@ export function defineGetPrivilegesRoutes({ router, authz }: RouteDefinitionPara
includeActions: schema.maybe(
schema.oneOf([schema.literal('true'), schema.literal('false')])
),
respectLicenseLevel: schema.maybe(
schema.oneOf([schema.literal('true'), schema.literal('false')])
),
}),
},
},
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 privilegesResponseBody = includeActions
? privileges

View file

@ -14,70 +14,71 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const expectedWithoutActions = {
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'],
};
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.
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')
@ -89,7 +90,7 @@ export default function ({ getService }: FtrProviderContext) {
// 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) => {
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.
@ -106,7 +107,9 @@ export default function ({ getService }: FtrProviderContext) {
if (!success) {
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);
});
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);
});
});
});
}