Add requireAllSpaces and disable options to FeatureKibanaPrivileges (#118001)

Co-authored-by: Larry Gregory <lgregorydev@gmail.com>
Co-authored-by: criamico <mariacristina.amico@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: Joe Portner <joseph.portner@elastic.co>
This commit is contained in:
Josh Dover 2022-01-06 13:10:12 +01:00 committed by GitHub
parent 90532485f9
commit eab0485fa3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1117 additions and 127 deletions

View file

@ -14,6 +14,18 @@ export interface FeatureKibanaPrivileges {
*/
excludeFromBasePrivileges?: boolean;
/**
* Whether or not this privilege should only be granted to `All Spaces *`. Should be used for features that do not
* support Spaces. Defaults to `false`.
*/
requireAllSpaces?: boolean;
/**
* Whether or not this privilege should be hidden in the roles UI and disallowed on the API. Defaults to `false`.
* @deprecated
*/
disabled?: boolean;
/**
* If this feature includes management sections, you can specify them here to control visibility of those
* pages based on user privileges.

View file

@ -75,6 +75,8 @@ const appCategorySchema = schema.object({
const kibanaPrivilegeSchema = schema.object({
excludeFromBasePrivileges: schema.maybe(schema.boolean()),
requireAllSpaces: schema.maybe(schema.boolean()),
disabled: schema.maybe(schema.boolean()),
management: schema.maybe(managementSchema),
catalogue: schema.maybe(catalogueSchema),
api: schema.maybe(schema.arrayOf(schema.string())),

View file

@ -17,9 +17,19 @@ export const createFeature = (
excludeFromBaseRead?: boolean;
privileges?: KibanaFeatureConfig['privileges'];
category?: KibanaFeatureConfig['category'];
requireAllSpacesOnAllPrivilege?: boolean;
disabledReadPrivilege?: boolean;
}
) => {
const { excludeFromBaseAll, excludeFromBaseRead, privileges, category, ...rest } = config;
const {
excludeFromBaseAll,
excludeFromBaseRead,
privileges,
category,
requireAllSpacesOnAllPrivilege: requireAllSpaces = false,
disabledReadPrivilege: disabled = false,
...rest
} = config;
return new KibanaFeature({
app: [],
category: category ?? { id: 'foo', label: 'foo' },
@ -35,6 +45,7 @@ export const createFeature = (
read: ['read-type'],
},
ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`],
requireAllSpaces,
},
read: {
excludeFromBasePrivileges: excludeFromBaseRead,
@ -43,6 +54,7 @@ export const createFeature = (
read: ['read-type'],
},
ui: ['read-ui', `read-${config.id}`],
disabled,
},
},
...rest,

View file

@ -49,6 +49,7 @@ const setup = (config: TestConfig) => {
onChangeAll={onChangeAll}
canCustomizeSubFeaturePrivileges={config.canCustomizeSubFeaturePrivileges}
privilegeIndex={config.privilegeIndex}
allSpacesSelected={true}
/>
);

View file

@ -7,7 +7,7 @@
import './feature_table.scss';
import type { EuiAccordionProps } from '@elastic/eui';
import type { EuiAccordionProps, EuiButtonGroupOptionProps } from '@elastic/eui';
import {
EuiAccordion,
EuiButtonGroup,
@ -44,6 +44,7 @@ interface Props {
onChange: (featureId: string, privileges: string[]) => void;
onChangeAll: (privileges: string[]) => void;
canCustomizeSubFeaturePrivileges: boolean;
allSpacesSelected: boolean;
disabled?: boolean;
}
@ -84,7 +85,8 @@ export class FeatureTable extends Component<Props, {}> {
(feature) =>
this.props.privilegeCalculator.getEffectivePrimaryFeaturePrivilege(
feature.id,
this.props.privilegeIndex
this.props.privilegeIndex,
this.props.allSpacesSelected
) != null
).length;
@ -269,28 +271,33 @@ export class FeatureTable extends Component<Props, {}> {
const selectedPrivilegeId =
this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId(
feature.id,
this.props.privilegeIndex
this.props.privilegeIndex,
this.props.allSpacesSelected
);
const options = primaryFeaturePrivileges.map((privilege) => {
return {
id: `${feature.id}_${privilege.id}`,
label: privilege.name,
isDisabled: this.props.disabled,
};
});
const options: EuiButtonGroupOptionProps[] = primaryFeaturePrivileges
.filter((privilege) => !privilege.disabled) // Don't show buttons for privileges that are disabled
.map((privilege) => {
const disabledDueToSpaceSelection =
privilege.requireAllSpaces && !this.props.allSpacesSelected;
return {
id: `${feature.id}_${privilege.id}`,
label: privilege.name,
isDisabled: this.props.disabled || disabledDueToSpaceSelection,
};
});
options.push({
id: `${feature.id}_${NO_PRIVILEGE_VALUE}`,
label: 'None',
isDisabled: this.props.disabled,
isDisabled: this.props.disabled ?? false,
});
let warningIcon = <EuiIconTip type="empty" content={null} />;
if (
this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges(
feature.id,
this.props.privilegeIndex
this.props.privilegeIndex,
this.props.allSpacesSelected
)
) {
warningIcon = (

View file

@ -26,7 +26,6 @@ export class PrivilegeFormCalculator {
*/
public getBasePrivilege(privilegeIndex: number) {
const entry = this.role.kibana[privilegeIndex];
const basePrivileges = this.kibanaPrivileges.getBasePrivileges(entry);
return basePrivileges.find((bp) => entry.base.includes(bp.id));
}
@ -49,8 +48,13 @@ export class PrivilegeFormCalculator {
* @param featureId the feature id to get the Primary Feature KibanaPrivilege for.
* @param privilegeIndex the index of the kibana privileges role component
*/
public getDisplayedPrimaryFeaturePrivilegeId(featureId: string, privilegeIndex: number) {
return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex)?.id;
public getDisplayedPrimaryFeaturePrivilegeId(
featureId: string,
privilegeIndex: number,
allSpacesSelected?: boolean
) {
return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex, allSpacesSelected)
?.id;
}
/**
@ -59,10 +63,18 @@ export class PrivilegeFormCalculator {
* @param featureId the feature id
* @param privilegeIndex the index of the kibana privileges role component
*/
public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) {
public hasCustomizedSubFeaturePrivileges(
featureId: string,
privilegeIndex: number,
allSpacesSelected?: boolean
) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex);
const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(
featureId,
privilegeIndex,
allSpacesSelected
);
const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([
this.role.kibana[privilegeIndex],
@ -81,19 +93,27 @@ export class PrivilegeFormCalculator {
*
* @param featureId the feature id
* @param privilegeIndex the index of the kibana privileges role component
* @param allSpacesSelected indicates if the privilege form is configured to grant access to all spaces.
*/
public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) {
public getEffectivePrimaryFeaturePrivilege(
featureId: string,
privilegeIndex: number,
allSpacesSelected?: boolean
) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const basePrivilege = this.getBasePrivilege(privilegeIndex);
const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex);
return feature
const effectivePrivilege = feature
.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true })
.find((fp) => {
return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp);
});
const correctSpacesSelected = effectivePrivilege?.requireAllSpaces ? allSpacesSelected : true;
const availablePrivileges = correctSpacesSelected && !effectivePrivilege?.disabled;
if (availablePrivileges) return effectivePrivilege;
}
/**
@ -264,25 +284,29 @@ export class PrivilegeFormCalculator {
* @param featureId the feature id to get the Primary Feature KibanaPrivilege for.
* @param privilegeIndex the index of the kibana privileges role component
*/
private getDisplayedPrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) {
private getDisplayedPrimaryFeaturePrivilege(
featureId: string,
privilegeIndex: number,
allSpacesSelected?: boolean
) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const basePrivilege = this.getBasePrivilege(privilegeIndex);
const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex);
return feature.getPrimaryFeaturePrivileges().find((fp) => {
const displayedPrivilege = feature.getPrimaryFeaturePrivileges().find((fp) => {
const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId();
const correspendingMinimalPrivilege = feature
const correspondingMinimalPrivilege = feature
.getMinimalFeaturePrivileges()
.find((mp) => mp.id === correspondingMinimalPrivilegeId)!;
// There is only one case where the minimal privileges aren't available:
// 1. Sub-feature privileges cannot be customized. When this is the case, the minimal privileges aren't registered with ES,
// so they end up represented in the UI as an empty privilege. Empty privileges cannot be granted other privileges, so if we
// encounter a minimal privilege that isn't granted by it's correspending primary, then we know we've encountered this scenario.
const hasMinimalPrivileges = fp.grantsPrivilege(correspendingMinimalPrivilege);
// encounter a minimal privilege that isn't granted by it's corresponding primary, then we know we've encountered this scenario.
const hasMinimalPrivileges = fp.grantsPrivilege(correspondingMinimalPrivilege);
return (
selectedFeaturePrivileges.includes(fp.id) ||
(hasMinimalPrivileges &&
@ -290,6 +314,10 @@ export class PrivilegeFormCalculator {
basePrivilege?.grantsPrivilege(fp)
);
});
const correctSpacesSelected = displayedPrivilege?.requireAllSpaces ? allSpacesSelected : true;
const availablePrivileges = correctSpacesSelected && !displayedPrivilege?.disabled;
if (availablePrivileges) return displayedPrivilege;
}
private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) {

View file

@ -22,8 +22,9 @@ import React, { Fragment, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Space, SpacesApiUi } from '../../../../../../../../spaces/public';
import { ALL_SPACES_ID } from '../../../../../../../common/constants';
import type { Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import type { KibanaPrivileges, SecuredFeature } from '../../../../model';
import type { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { FeatureTableCell } from '../feature_table_cell';
import type { EffectiveFeaturePrivileges } from './privilege_summary_calculator';
@ -43,6 +44,17 @@ function getColumnKey(entry: RoleKibanaPrivilege) {
return `privilege_entry_${entry.spaces.join('|')}`;
}
function showPrivilege(allSpacesSelected: boolean, primaryFeature?: PrimaryFeaturePrivilege) {
if (
primaryFeature?.name == null ||
primaryFeature?.disabled ||
(primaryFeature.requireAllSpaces && !allSpacesSelected)
) {
return 'None';
}
return primaryFeature?.name;
}
export const PrivilegeSummaryTable = (props: PrivilegeSummaryTableProps) => {
const [expandedFeatures, setExpandedFeatures] = useState<string[]>([]);
@ -145,7 +157,11 @@ export const PrivilegeSummaryTable = (props: PrivilegeSummaryTableProps) => {
hasCustomizedSubFeaturePrivileges ? 'additionalPrivilegesGranted' : ''
}`}
>
{primary?.name ?? 'None'} {iconTip}
{showPrivilege(
props.spaces.some((space) => space.id === ALL_SPACES_ID),
primary
)}{' '}
{iconTip}
</span>
);
},

View file

@ -12,7 +12,7 @@ import { findTestSubject, mountWithIntl } from '@kbn/test/jest';
import type { Space } from '../../../../../../../../spaces/public';
import type { Role } from '../../../../../../../common/model';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { createFeature, kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { FeatureTable } from '../feature_table';
import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__';
@ -300,7 +300,7 @@ describe('PrivilegeSpaceForm', () => {
expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(0);
});
it('allows all feature privileges to be changed via "change all"', () => {
it('allows all feature privileges to be changed via "change read"', () => {
const role = createRole([
{
base: [],
@ -391,4 +391,303 @@ describe('PrivilegeSpaceForm', () => {
expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize);
});
describe('Feature with a disabled `read` privilege', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'],
},
spaces: ['foo'],
},
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
]);
const extendedKibanaFeatures = [
...kibanaFeatures,
createFeature({
id: 'no_sub_features_disabled_read',
name: 'Feature 1: No Sub Features and read disabled',
disabledReadPrivilege: true,
}),
];
const kibanaPrivileges = createKibanaPrivileges(extendedKibanaFeatures);
const onChange = jest.fn();
beforeEach(() => {
onChange.mockReset();
});
it('still allow other features privileges to be changed via "change read"', () => {
const wrapper = mountWithIntl(
<PrivilegeSpaceForm
role={role}
spaces={displaySpaces}
kibanaPrivileges={kibanaPrivileges}
canCustomizeSubFeaturePrivileges={true}
privilegeIndex={0}
onChange={onChange}
onCancel={jest.fn()}
/>
);
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click');
findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click');
expect(Object.keys(onChange.mock.calls[0][0].kibana[0].feature)).not.toContain(
'no_sub_features_disabled_read'
);
expect(onChange).toHaveBeenCalledWith(
createRole([
{
base: [],
feature: {
excluded_from_base: ['read'],
with_excluded_sub_features: ['read'],
no_sub_features: ['read'],
with_sub_features: ['read'],
},
spaces: ['foo'],
},
// this set remains unchanged from the original
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
])
);
});
it('still allow all privileges to be changed via "change all"', () => {
const wrapper = mountWithIntl(
<PrivilegeSpaceForm
role={role}
spaces={displaySpaces}
kibanaPrivileges={kibanaPrivileges}
canCustomizeSubFeaturePrivileges={true}
privilegeIndex={0}
onChange={onChange}
onCancel={jest.fn()}
/>
);
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click');
findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click');
expect(onChange).toHaveBeenCalledWith(
createRole([
{
base: [],
feature: {
excluded_from_base: ['all'],
with_excluded_sub_features: ['all'],
no_sub_features: ['all'],
no_sub_features_disabled_read: ['all'],
with_sub_features: ['all'],
},
spaces: ['foo'],
},
// this set remains unchanged from the original
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
])
);
});
});
describe('Feature with requireAllSpaces on all privileges', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'],
},
spaces: ['foo'],
},
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
]);
const extendedKibanaFeatures = [
...kibanaFeatures,
createFeature({
id: 'no_sub_features_require_all_space',
name: 'Feature 1: No Sub Features and all privilege require all space',
requireAllSpacesOnAllPrivilege: true,
}),
];
const kibanaPrivileges = createKibanaPrivileges(extendedKibanaFeatures);
const onChange = jest.fn();
beforeEach(() => {
onChange.mockReset();
});
it('still allow all features privileges to be changed via "change read" in foo space', () => {
const wrapper = mountWithIntl(
<PrivilegeSpaceForm
role={role}
spaces={displaySpaces}
kibanaPrivileges={kibanaPrivileges}
canCustomizeSubFeaturePrivileges={true}
privilegeIndex={0}
onChange={onChange}
onCancel={jest.fn()}
/>
);
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click');
findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click');
expect(onChange).toHaveBeenCalledWith(
createRole([
{
base: [],
feature: {
excluded_from_base: ['read'],
with_excluded_sub_features: ['read'],
no_sub_features: ['read'],
no_sub_features_require_all_space: ['read'],
with_sub_features: ['read'],
},
spaces: ['foo'],
},
// this set remains unchanged from the original
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
])
);
});
it('still allow other features privileges to be changed via "change all" in foo space', () => {
const wrapper = mountWithIntl(
<PrivilegeSpaceForm
role={role}
spaces={displaySpaces}
kibanaPrivileges={kibanaPrivileges}
canCustomizeSubFeaturePrivileges={true}
privilegeIndex={0}
onChange={onChange}
onCancel={jest.fn()}
/>
);
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click');
findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click');
expect(Object.keys(onChange.mock.calls[0][0].kibana[0].feature)).not.toContain(
'no_sub_features_require_all_space'
);
expect(onChange).toHaveBeenCalledWith(
createRole([
{
base: [],
feature: {
excluded_from_base: ['all'],
with_excluded_sub_features: ['all'],
no_sub_features: ['all'],
with_sub_features: ['all'],
},
spaces: ['foo'],
},
// this set remains unchanged from the original
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
])
);
});
it('still allow all features privileges to be changed via "change all" in all space', () => {
const roleAllSpace = createRole([
{
base: [],
feature: {
with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'],
},
spaces: ['*'],
},
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSpaceForm
role={roleAllSpace}
spaces={displaySpaces}
kibanaPrivileges={kibanaPrivileges}
canCustomizeSubFeaturePrivileges={true}
privilegeIndex={0}
onChange={onChange}
onCancel={jest.fn()}
/>
);
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click');
findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click');
expect(onChange).toHaveBeenCalledWith(
createRole([
{
base: [],
feature: {
excluded_from_base: ['all'],
with_excluded_sub_features: ['all'],
no_sub_features: ['all'],
no_sub_features_require_all_space: ['all'],
with_sub_features: ['all'],
},
spaces: ['*'],
},
// this set remains unchanged from the original
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['bar'],
},
])
);
});
test.todo(
'should unset the feature privilege and all sub-feature privileges when "* All spaces" is removed'
);
});
});

View file

@ -30,7 +30,8 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Space } from '../../../../../../../../spaces/public';
import type { Role } from '../../../../../../../common/model';
import { ALL_SPACES_ID } from '../../../../../../../common/constants';
import type { FeaturesPrivileges, Role } from '../../../../../../../common/model';
import { copyRole } from '../../../../../../../common/model';
import type { KibanaPrivileges } from '../../../../model';
import { CUSTOM_PRIVILEGE_VALUE } from '../constants';
@ -261,6 +262,7 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
privilegeIndex={this.state.privilegeIndex}
canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges}
disabled={this.state.selectedBasePrivilege.length > 0 || !hasSelectedSpaces}
allSpacesSelected={this.state.selectedSpaceIds.includes(ALL_SPACES_ID)}
/>
{this.requiresGlobalPrivilegeWarning() && (
@ -427,6 +429,7 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
const form = role.kibana[this.state.privilegeIndex];
form.spaces = [...selectedSpaceIds];
form.feature = this.resetRoleFeature(form.feature, selectedSpaceIds); // Remove any feature privilege(s) that cannot currently be selected
this.setState({
selectedSpaceIds,
@ -459,6 +462,28 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
});
};
private resetRoleFeature = (roleFeature: FeaturesPrivileges, selectedSpaceIds: string[]) => {
const securedFeatures = this.props.kibanaPrivileges.getSecuredFeatures();
return Object.entries(roleFeature).reduce((features, [featureId, privileges]) => {
if (!Array.isArray(privileges)) {
return features;
}
const securedFeature = securedFeatures.find((sf) => sf.id === featureId);
const primaryFeaturePrivilege = securedFeature
?.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true })
.find((pfp) => privileges.includes(pfp.id)) ?? { disabled: false, requireAllSpaces: false };
const newFeaturePrivileges =
primaryFeaturePrivilege?.disabled ||
(primaryFeaturePrivilege?.requireAllSpaces && !selectedSpaceIds.includes(ALL_SPACES_ID))
? [] // The primary feature privilege cannot be selected; remove that and any selected sub-feature privileges, too
: privileges;
return {
...features,
...(newFeaturePrivileges.length && { [featureId]: newFeaturePrivileges }),
};
}, {});
};
private getDisplayedBasePrivilege = () => {
const basePrivilege = this.state.privilegeCalculator.getBasePrivilege(
this.state.privilegeIndex
@ -472,34 +497,53 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
};
private onFeaturePrivilegesChange = (featureId: string, privileges: string[]) => {
const role = copyRole(this.state.role);
const form = role.kibana[this.state.privilegeIndex];
if (privileges.length === 0) {
delete form.feature[featureId];
} else {
form.feature[featureId] = [...privileges];
}
this.setState({
role,
privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role),
});
this.setRole(privileges, featureId);
};
private onChangeAllFeaturePrivileges = (privileges: string[]) => {
this.setRole(privileges);
};
private setRole(privileges: string[], featureId?: string) {
const role = copyRole(this.state.role);
const entry = role.kibana[this.state.privilegeIndex];
if (privileges.length === 0) {
entry.feature = {};
if (featureId) {
delete entry.feature[featureId];
} else {
entry.feature = {};
}
} else {
this.props.kibanaPrivileges.getSecuredFeatures().forEach((feature) => {
let securedFeaturesToSet = this.props.kibanaPrivileges.getSecuredFeatures();
if (featureId) {
securedFeaturesToSet = [securedFeaturesToSet.find((sf) => sf.id === featureId)!];
}
securedFeaturesToSet.forEach((feature) => {
const nextFeaturePrivilege = feature
.getPrimaryFeaturePrivileges()
.find((pfp) => privileges.includes(pfp.id));
.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true })
.find((pfp) => {
if (
pfp?.disabled ||
(pfp?.requireAllSpaces && !this.state.selectedSpaceIds.includes(ALL_SPACES_ID))
) {
return false;
}
return Array.isArray(privileges) && privileges.includes(pfp.id);
});
let newPrivileges: string[] = [];
if (nextFeaturePrivilege) {
entry.feature[feature.id] = [nextFeaturePrivilege.id];
newPrivileges = [nextFeaturePrivilege.id];
feature.getSubFeaturePrivileges().forEach((psf) => {
if (Array.isArray(privileges) && privileges.includes(psf.id)) {
newPrivileges.push(psf.id);
}
});
}
if (newPrivileges.length === 0) {
delete entry.feature[feature.id];
} else {
entry.feature[feature.id] = newPrivileges;
}
});
}
@ -507,7 +551,7 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
role,
privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role),
});
};
}
private canSave = () => {
if (this.state.selectedSpaceIds.length === 0) {

View file

@ -58,7 +58,7 @@ export class KibanaPrivileges {
public createCollectionFromRoleKibanaPrivileges(roleKibanaPrivileges: RoleKibanaPrivilege[]) {
const filterAssigned = (assignedPrivileges: string[]) => (privilege: KibanaPrivilege) =>
assignedPrivileges.includes(privilege.id);
Array.isArray(assignedPrivileges) && assignedPrivileges.includes(privilege.id);
const privileges: KibanaPrivilege[] = roleKibanaPrivileges
.map((entry) => {

View file

@ -27,4 +27,12 @@ export class PrimaryFeaturePrivilege extends KibanaPrivilege {
}
return `minimal_${this.id}`;
}
public get requireAllSpaces() {
return this.config.requireAllSpaces ?? false;
}
public get disabled() {
return this.config.disabled ?? false;
}
}

View file

@ -0,0 +1,251 @@
/*
* 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 { omit, pick } from 'lodash';
import { KibanaFeature } from '../../../../features/server';
import { transformElasticsearchRoleToRole } from './elasticsearch_role';
import type { ElasticsearchRole } from './elasticsearch_role';
const roles = [
{
name: 'global-base-all',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['all'],
resources: ['*'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'global-base-read',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['read'],
resources: ['*'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'global-foo-all',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['feature_foo.all'],
resources: ['*'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'global-foo-read',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['feature_foo.read'],
resources: ['*'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'default-base-all',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['space_all'],
resources: ['space:default'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'default-base-read',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['space_read'],
resources: ['space:default'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'default-foo-all',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['feature_foo.all'],
resources: ['space:default'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
{
name: 'default-foo-read',
cluster: [],
indices: [],
applications: [
{
application: 'kibana-.kibana',
privileges: ['feature_foo.read'],
resources: ['space:default'],
},
],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
},
];
function testRoles(
testName: string,
features: KibanaFeature[],
elasticsearchRoles: ElasticsearchRole[],
expected: any
) {
const transformedRoles = elasticsearchRoles.map((role) => {
const transformedRole = transformElasticsearchRoleToRole(
features,
omit(role, 'name'),
role.name,
'kibana-.kibana'
);
return pick(transformedRole, ['name', '_transform_error']);
});
it(`${testName}`, () => {
expect(transformedRoles).toEqual(expected);
});
}
describe('#transformElasticsearchRoleToRole', () => {
const featuresWithRequireAllSpaces: KibanaFeature[] = [
new KibanaFeature({
id: 'foo',
name: 'KibanaFeatureWithAllSpaces',
app: ['kibana-.kibana'],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
requireAllSpaces: true,
savedObject: {
all: [],
read: [],
},
ui: [],
},
read: {
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
}),
];
const featuresWithReadDisabled: KibanaFeature[] = [
new KibanaFeature({
id: 'foo',
name: 'Foo KibanaFeatureWithReadDisabled',
app: ['kibana-.kibana'],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
savedObject: {
all: [],
read: [],
},
ui: [],
},
read: {
disabled: true,
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
}),
];
testRoles('#When features has requireAllSpaces=true', featuresWithRequireAllSpaces, roles, [
{ name: 'global-base-all', _transform_error: [] },
{ name: 'global-base-read', _transform_error: [] },
{ name: 'global-foo-all', _transform_error: [] },
{ name: 'global-foo-read', _transform_error: [] },
{ name: 'default-base-all', _transform_error: [] },
{ name: 'default-base-read', _transform_error: [] },
{ name: 'default-foo-all', _transform_error: ['kibana'] },
{ name: 'default-foo-read', _transform_error: [] },
]);
testRoles(
'#When features has requireAllSpaces=false and read disabled',
featuresWithReadDisabled,
roles,
[
{ name: 'global-base-all', _transform_error: [] },
{ name: 'global-base-read', _transform_error: [] },
{ name: 'global-foo-all', _transform_error: [] },
{ name: 'global-foo-read', _transform_error: ['kibana'] },
{ name: 'default-base-all', _transform_error: [] },
{ name: 'default-base-read', _transform_error: [] },
{ name: 'default-foo-all', _transform_error: [] },
{ name: 'default-foo-read', _transform_error: ['kibana'] },
]
);
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { KibanaFeature } from '../../../../features/common';
import {
GLOBAL_RESOURCE,
RESERVED_PRIVILEGES_APPLICATION_WILDCARD,
@ -25,15 +26,16 @@ export type ElasticsearchRole = Pick<Role, 'name' | 'metadata' | 'transient_meta
};
export function transformElasticsearchRoleToRole(
features: KibanaFeature[],
elasticsearchRole: Omit<ElasticsearchRole, 'name'>,
name: string,
application: string
): Role {
const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges(
features,
elasticsearchRole.applications,
application
);
return {
name,
metadata: elasticsearchRole.metadata,
@ -53,6 +55,7 @@ export function transformElasticsearchRoleToRole(
}
function transformRoleApplicationsToKibanaPrivileges(
features: KibanaFeature[],
roleApplications: ElasticsearchRole['applications'],
application: string
) {
@ -184,6 +187,44 @@ function transformRoleApplicationsToKibanaPrivileges(
};
}
// if a feature privilege requires all spaces, but is assigned to other spaces, we won't transform these
if (
roleKibanaApplications.some(
(entry) =>
!entry.resources.includes(GLOBAL_RESOURCE) &&
features.some((f) =>
Object.entries(f.privileges ?? {}).some(
([privName, featurePrivilege]) =>
featurePrivilege.requireAllSpaces &&
entry.privileges.includes(
PrivilegeSerializer.serializeFeaturePrivilege(f.id, privName)
)
)
)
)
) {
return {
success: false,
};
}
// if a feature privilege has been disabled we won't transform these
if (
roleKibanaApplications.some((entry) =>
features.some((f) =>
Object.entries(f.privileges ?? {}).some(
([privName, featurePrivilege]) =>
featurePrivilege.disabled &&
entry.privileges.includes(PrivilegeSerializer.serializeFeaturePrivilege(f.id, privName))
)
)
)
) {
return {
success: false,
};
}
return {
success: true,
value: roleKibanaApplications.map(({ resources, privileges }) => {

View file

@ -17,15 +17,17 @@ const application = `kibana-${kibanaIndexName}`;
describe('#getPrivilegeDeprecationsService', () => {
describe('#getKibanaRolesByFeatureId', () => {
const mockAsCurrentUser = elasticsearchServiceMock.createScopedClusterClient();
const mockGetFeatures = jest.fn().mockResolvedValue([]);
const mockLicense = licenseMock.create();
const mockLogger = loggingSystemMock.createLogger();
const authz = { applicationName: application };
const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService(
const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService({
authz,
mockLicense,
mockLogger
);
getFeatures: mockGetFeatures,
license: mockLicense,
logger: mockLogger,
});
it('happy path to find siem roles with feature_siem privileges', async () => {
mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue(

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { Logger } from 'src/core/server';
import type { KibanaFeature } from '../../../features/common';
import type { SecurityLicense } from '../../common/licensing';
import type {
PrivilegeDeprecationsRolesByFeatureIdRequest,
@ -17,11 +18,17 @@ import { transformElasticsearchRoleToRole } from '../authorization';
import type { AuthorizationServiceSetupInternal, ElasticsearchRole } from '../authorization';
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
export const getPrivilegeDeprecationsService = (
authz: Pick<AuthorizationServiceSetupInternal, 'applicationName'>,
license: SecurityLicense,
logger: Logger
) => {
export const getPrivilegeDeprecationsService = ({
authz,
getFeatures,
license,
logger,
}: {
authz: Pick<AuthorizationServiceSetupInternal, 'applicationName'>;
getFeatures(): Promise<KibanaFeature[]>;
license: SecurityLicense;
logger: Logger;
}) => {
const getKibanaRolesByFeatureId = async ({
context,
featureId,
@ -34,11 +41,13 @@ export const getPrivilegeDeprecationsService = (
}
let kibanaRoles;
try {
const { body: elasticsearchRoles } = await context.esClient.asCurrentUser.security.getRole<
Record<string, ElasticsearchRole>
>();
const [features, { body: elasticsearchRoles }] = await Promise.all([
getFeatures(),
context.esClient.asCurrentUser.security.getRole<Record<string, ElasticsearchRole>>(),
]);
kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) =>
transformElasticsearchRoleToRole(
features,
// @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]`
elasticsearchRole,
roleName,

View file

@ -324,11 +324,13 @@ export class SecurityPlugin
mode: this.authorizationSetup.mode,
},
license,
privilegeDeprecationsService: getPrivilegeDeprecationsService(
this.authorizationSetup,
privilegeDeprecationsService: getPrivilegeDeprecationsService({
authz: this.authorizationSetup,
getFeatures: () =>
startServicesPromise.then((services) => services.features.getKibanaFeatures()),
license,
this.logger.get('deprecations')
),
logger: this.logger.get('deprecations'),
}),
});
}

View file

@ -32,6 +32,8 @@ describe('GET role', () => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
mockRouteDefinitionParams.authz.applicationName = application;
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]);
const mockContext = {
core: coreMock.createRequestHandlerContext(),
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any,

View file

@ -12,7 +12,7 @@ import { wrapIntoCustomErrorResponse } from '../../../errors';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
import { transformElasticsearchRoleToRole } from './model';
export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) {
export function defineGetRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) {
router.get(
{
path: '/api/security/role/{name}',
@ -22,15 +22,18 @@ export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) {
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const { body: elasticsearchRoles } =
const [features, { body: elasticsearchRoles }] = await Promise.all([
getFeatures(),
await context.core.elasticsearch.client.asCurrentUser.security.getRole({
name: request.params.name,
});
}),
]);
const elasticsearchRole = elasticsearchRoles[request.params.name];
if (elasticsearchRole) {
return response.ok({
body: transformElasticsearchRoleToRole(
features,
// @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]`
elasticsearchRole,
request.params.name,

View file

@ -32,6 +32,8 @@ describe('GET all roles', () => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
mockRouteDefinitionParams.authz.applicationName = application;
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]);
const mockContext = {
core: coreMock.createRequestHandlerContext(),
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any,

View file

@ -11,21 +11,24 @@ import { createLicensedRouteHandler } from '../../licensed_route_handler';
import type { ElasticsearchRole } from './model';
import { transformElasticsearchRoleToRole } from './model';
export function defineGetAllRolesRoutes({ router, authz }: RouteDefinitionParams) {
export function defineGetAllRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) {
router.get(
{ path: '/api/security/role', validate: false },
createLicensedRouteHandler(async (context, request, response) => {
try {
const { body: elasticsearchRoles } =
const [features, { body: elasticsearchRoles }] = await Promise.all([
getFeatures(),
await context.core.elasticsearch.client.asCurrentUser.security.getRole<
Record<string, ElasticsearchRole>
>();
>(),
]);
// Transform elasticsearch roles into Kibana roles and return in a list sorted by the role name.
return response.ok({
body: Object.entries(elasticsearchRoles)
.map(([roleName, elasticsearchRole]) =>
transformElasticsearchRoleToRole(
features,
// @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[]
elasticsearchRole,
roleName,

View file

@ -7,4 +7,8 @@
export type { ElasticsearchRole } from '../../../../authorization';
export { transformElasticsearchRoleToRole } from '../../../../authorization';
export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload';
export {
getPutPayloadSchema,
transformPutPayloadToElasticsearchRole,
validateKibanaPrivileges,
} from './put_payload';

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { getPutPayloadSchema } from './put_payload';
import { KibanaFeature } from '../../../../../../features/common';
import { ALL_SPACES_ID } from '../../../../../common/constants';
import { getPutPayloadSchema, validateKibanaPrivileges } from './put_payload';
const basePrivilegeNamesMap = {
global: ['all', 'read'],
@ -345,3 +347,121 @@ describe('Put payload schema', () => {
`);
});
});
describe('validateKibanaPrivileges', () => {
const fooFeature = new KibanaFeature({
id: 'foo',
name: 'Foo',
privileges: {
all: {
requireAllSpaces: true,
savedObject: {
all: [],
read: [],
},
ui: [],
},
read: {
disabled: true,
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
app: [],
category: { id: 'foo', label: 'foo' },
});
test('allows valid privileges', () => {
expect(
validateKibanaPrivileges(
[fooFeature],
[
{
spaces: [ALL_SPACES_ID],
base: [],
feature: {
foo: ['all'],
},
},
]
).validationErrors
).toEqual([]);
});
test('does not reject unknown features', () => {
expect(
validateKibanaPrivileges(
[fooFeature],
[
{
spaces: [ALL_SPACES_ID],
base: [],
feature: {
foo: ['all'],
bar: ['all'],
},
},
]
).validationErrors
).toEqual([]);
});
test('returns errors if requireAllSpaces: true and not all spaces specified', () => {
expect(
validateKibanaPrivileges(
[fooFeature],
[
{
spaces: ['foo-space'],
base: [],
feature: {
foo: ['all'],
},
},
]
).validationErrors
).toEqual([
`Feature privilege [foo.all] requires all spaces to be selected but received [foo-space]`,
]);
});
test('returns errors if disabled: true and privilege is specified', () => {
expect(
validateKibanaPrivileges(
[fooFeature],
[
{
spaces: [ALL_SPACES_ID],
base: [],
feature: {
foo: ['read'],
},
},
]
).validationErrors
).toEqual([`Feature [foo] does not support privilege [read].`]);
});
test('returns multiple errors when necessary', () => {
expect(
validateKibanaPrivileges(
[fooFeature],
[
{
spaces: ['foo-space'],
base: [],
feature: {
foo: ['all', 'read'],
},
},
]
).validationErrors
).toEqual([
`Feature privilege [foo.all] requires all spaces to be selected but received [foo-space]`,
`Feature [foo] does not support privilege [read].`,
]);
});
});

View file

@ -11,7 +11,8 @@ import type { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import type { ElasticsearchRole } from '.';
import { GLOBAL_RESOURCE } from '../../../../../common/constants';
import type { KibanaFeature } from '../../../../../../features/common';
import { ALL_SPACES_ID, GLOBAL_RESOURCE } from '../../../../../common/constants';
import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer';
import { ResourceSerializer } from '../../../../authorization/resource_serializer';
@ -302,3 +303,50 @@ const transformPrivilegesToElasticsearchPrivileges = (
};
});
};
export const validateKibanaPrivileges = (
kibanaFeatures: KibanaFeature[],
kibanaPrivileges: PutPayloadSchemaType['kibana']
) => {
const validationErrors = (kibanaPrivileges ?? []).flatMap((priv) => {
const forAllSpaces = priv.spaces.includes(ALL_SPACES_ID);
return Object.entries(priv.feature ?? {}).flatMap(([featureId, feature]) => {
const errors: string[] = [];
const kibanaFeature = kibanaFeatures.find((f) => f.id === featureId);
if (!kibanaFeature) return errors;
if (feature.includes('all')) {
if (kibanaFeature.privileges?.all.disabled) {
errors.push(`Feature [${featureId}] does not support privilege [all].`);
}
if (kibanaFeature.privileges?.all.requireAllSpaces && !forAllSpaces) {
errors.push(
`Feature privilege [${featureId}.all] requires all spaces to be selected but received [${priv.spaces.join(
','
)}]`
);
}
}
if (feature.includes('read')) {
if (kibanaFeature.privileges?.read.disabled) {
errors.push(`Feature [${featureId}] does not support privilege [read].`);
}
if (kibanaFeature.privileges?.read.requireAllSpaces && !forAllSpaces) {
errors.push(
`Feature privilege [${featureId}.read] requires all spaces to be selected but received [${priv.spaces.join(
','
)}]`
);
}
}
return errors;
});
});
return { validationErrors };
};

View file

@ -56,11 +56,19 @@ interface TestOptions {
apiArguments?: { get: unknown[]; put: unknown[] };
recordSubFeaturePrivilegeUsage?: boolean;
};
features?: KibanaFeature[];
}
const putRoleTest = (
description: string,
{ name, payload, licenseCheckResult = { state: 'valid' }, apiResponses, asserts }: TestOptions
{
name,
payload,
licenseCheckResult = { state: 'valid' },
apiResponses,
asserts,
features,
}: TestOptions
) => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
@ -88,43 +96,45 @@ const putRoleTest = (
securityFeatureUsageServiceMock.createStartContract()
);
mockRouteDefinitionParams.getFeatures.mockResolvedValue([
new KibanaFeature({
id: 'feature_1',
name: 'feature 1',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
ui: [],
savedObject: { all: [], read: [] },
mockRouteDefinitionParams.getFeatures.mockResolvedValue(
features ?? [
new KibanaFeature({
id: 'feature_1',
name: 'feature 1',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
ui: [],
savedObject: { all: [], read: [] },
},
read: {
ui: [],
savedObject: { all: [], read: [] },
},
},
read: {
ui: [],
savedObject: { all: [], read: [] },
},
},
subFeatures: [
{
name: 'sub feature 1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub_feature_privilege_1',
name: 'first sub-feature privilege',
includeIn: 'none',
ui: [],
savedObject: { all: [], read: [] },
},
],
},
],
},
],
}),
]);
subFeatures: [
{
name: 'sub feature 1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub_feature_privilege_1',
name: 'first sub-feature privilege',
includeIn: 'none',
ui: [],
savedObject: { all: [], read: [] },
},
],
},
],
},
],
}),
]
);
definePutRolesRoutes(mockRouteDefinitionParams);
const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls;
@ -207,6 +217,56 @@ describe('PUT role', () => {
licenseCheckResult: { state: 'invalid', message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
describe('feature validation', () => {
const fooFeature = new KibanaFeature({
id: 'bar',
name: 'bar',
privileges: {
all: {
requireAllSpaces: true,
savedObject: {
all: [],
read: [],
},
ui: [],
},
read: {
disabled: true,
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
app: [],
category: { id: 'bar', label: 'bar' },
});
putRoleTest('returns validation errors', {
name: 'bar-role',
payload: {
kibana: [
{
spaces: ['bar-space'],
base: [],
feature: {
bar: ['all', 'read'],
},
},
],
},
features: [fooFeature],
asserts: {
statusCode: 400,
result: {
message:
'Role cannot be updated due to validation errors: ["Feature privilege [bar.all] requires all spaces to be selected but received [bar-space]","Feature [bar] does not support privilege [read]."]',
},
},
});
});
});
describe('success', () => {

View file

@ -12,7 +12,11 @@ import type { KibanaFeature } from '../../../../../features/common';
import { wrapIntoCustomErrorResponse } from '../../../errors';
import type { RouteDefinitionParams } from '../../index';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
import { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './model';
import {
getPutPayloadSchema,
transformPutPayloadToElasticsearchRole,
validateKibanaPrivileges,
} from './model';
const roleGrantsSubFeaturePrivileges = (
features: KibanaFeature[],
@ -62,11 +66,24 @@ export function definePutRolesRoutes({
const { name } = request.params;
try {
const { body: rawRoles } =
await context.core.elasticsearch.client.asCurrentUser.security.getRole(
const [features, { body: rawRoles }] = await Promise.all([
getFeatures(),
context.core.elasticsearch.client.asCurrentUser.security.getRole(
{ name: request.params.name },
{ ignore: [404] }
);
),
]);
const { validationErrors } = validateKibanaPrivileges(features, request.body.kibana);
if (validationErrors.length) {
return response.badRequest({
body: {
message: `Role cannot be updated due to validation errors: ${JSON.stringify(
validationErrors
)}`,
},
});
}
const body = transformPutPayloadToElasticsearchRole(
request.body,
@ -74,14 +91,11 @@ export function definePutRolesRoutes({
rawRoles[name] ? rawRoles[name].applications : []
);
const [features] = await Promise.all([
getFeatures(),
context.core.elasticsearch.client.asCurrentUser.security.putRole({
name: request.params.name,
// @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security
body,
}),
]);
await context.core.elasticsearch.client.asCurrentUser.security.putRole({
name: request.params.name,
// @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security
body,
});
if (roleGrantsSubFeaturePrivileges(features, request.body)) {
getFeatureUsageService().recordSubFeaturePrivilegeUsage();