Support for sub-feature privileges (#60563)

* initial server-side support for sub-feature privileges (#57507)

* initial server-side support for sub-feature privileges

* start addressing PR feedback

* renaming interfaces

* move privilege id collision check to security plugin

* additional testing

* change featurePrivilegeIterator import location

* fix link assertions following rebase from master

* Initial UI support for sub-feature privileges (#59198)

* Initial UI support for sub-feature privileges

* Address PR feedback

* display deleted spaces correctly in the privilege summary

* additional testing

* update snapshot

* Enables sub-feature privileges for gold+ licenses (#59750)

* enables sub-feature privileges for gold+ licenses

* Address PR feedback

* address platform review feedback
This commit is contained in:
Larry Gregory 2020-03-24 11:12:49 -04:00 committed by GitHub
parent f371acff33
commit b82cc6ed4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
180 changed files with 12486 additions and 7108 deletions

View file

@ -890,7 +890,8 @@ export class DashboardAppController {
share.toggleShareContextMenu({
anchorElement,
allowEmbed: true,
allowShortUrl: !dashboardConfig.getHideWriteControls(),
allowShortUrl:
!dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl,
shareableUrl: unhashUrl(window.location.href),
objectId: dash.id,
objectType: 'dashboard',

View file

@ -96,6 +96,7 @@ export const apm: LegacyPluginInitializer = kibana => {
name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', {
defaultMessage: 'APM'
}),
order: 900,
icon: 'apmApp',
navLinkId: 'apm',
app: ['apm', 'kibana'],
@ -103,6 +104,7 @@ export const apm: LegacyPluginInitializer = kibana => {
// see x-pack/plugins/features/common/feature_kibana_privileges.ts
privileges: {
all: {
app: ['apm', 'kibana'],
api: ['apm', 'apm_write', 'actions-read', 'alerting-read'],
catalogue: ['apm'],
savedObject: {
@ -121,6 +123,7 @@ export const apm: LegacyPluginInitializer = kibana => {
]
},
read: {
app: ['apm', 'kibana'],
api: ['apm', 'actions-read', 'alerting-read'],
catalogue: ['apm'],
savedObject: {

View file

@ -37,6 +37,7 @@ export const graph: LegacyPluginInitializer = kibana => {
name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', {
defaultMessage: 'Graph',
}),
order: 1200,
icon: 'graphApp',
navLinkId: 'graph',
app: ['graph', 'kibana'],
@ -44,6 +45,8 @@ export const graph: LegacyPluginInitializer = kibana => {
validLicenses: ['platinum', 'enterprise', 'trial'],
privileges: {
all: {
app: ['graph', 'kibana'],
catalogue: ['graph'],
savedObject: {
all: ['graph-workspace'],
read: ['index-pattern'],
@ -51,6 +54,8 @@ export const graph: LegacyPluginInitializer = kibana => {
ui: ['save', 'delete'],
},
read: {
app: ['graph', 'kibana'],
catalogue: ['graph'],
savedObject: {
all: [],
read: ['index-pattern', 'graph-workspace'],

View file

@ -23,12 +23,15 @@ export class MapPlugin {
name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', {
defaultMessage: 'Maps',
}),
order: 600,
icon: APP_ICON,
navLinkId: APP_ID,
app: [APP_ID, 'kibana'],
catalogue: [APP_ID],
privileges: {
all: {
app: [APP_ID, 'kibana'],
catalogue: [APP_ID],
savedObject: {
all: [MAP_SAVED_OBJECT_TYPE, 'query'],
read: ['index-pattern'],
@ -36,6 +39,8 @@ export class MapPlugin {
ui: ['save', 'show', 'saveQuery'],
},
read: {
app: [APP_ID, 'kibana'],
catalogue: [APP_ID],
savedObject: {
all: [],
read: [MAP_SAVED_OBJECT_TYPE, 'index-pattern', 'query'],

View file

@ -97,12 +97,15 @@ export class Plugin {
name: i18n.translate('xpack.siem.featureRegistry.linkSiemTitle', {
defaultMessage: 'SIEM',
}),
order: 1100,
icon: 'securityAnalyticsApp',
navLinkId: 'siem',
app: ['siem', 'kibana'],
catalogue: ['siem'],
privileges: {
all: {
app: ['siem', 'kibana'],
catalogue: ['siem'],
api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'],
savedObject: {
all: [
@ -128,6 +131,8 @@ export class Plugin {
],
},
read: {
app: ['siem', 'kibana'],
catalogue: ['siem'],
api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'],
savedObject: {
all: ['alert', 'action', 'action_task_params'],

View file

@ -5,12 +5,12 @@
*/
import KbnServer from 'src/legacy/server/kbn_server';
import { Feature, FeatureWithAllOrReadPrivileges } from '../../../../plugins/features/server';
import { Feature, FeatureConfig } from '../../../../plugins/features/server';
import { XPackInfo, XPackInfoOptions } from './lib/xpack_info';
export { XPackFeature } from './lib/xpack_info';
export interface XPackMainPlugin {
info: XPackInfo;
getFeatures(): Feature[];
registerFeature(feature: FeatureWithAllOrReadPrivileges): void;
registerFeature(feature: FeatureConfig): void;
}

View file

@ -32,12 +32,15 @@ export class CanvasPlugin implements Plugin {
plugins.features.registerFeature({
id: 'canvas',
name: 'Canvas',
order: 400,
icon: 'canvasApp',
navLinkId: 'canvas',
app: ['canvas', 'kibana'],
catalogue: ['canvas'],
privileges: {
all: {
app: ['canvas', 'kibana'],
catalogue: ['canvas'],
savedObject: {
all: ['canvas-workpad', 'canvas-element'],
read: ['index-pattern'],
@ -45,6 +48,8 @@ export class CanvasPlugin implements Plugin {
ui: ['save', 'show'],
},
read: {
app: ['canvas', 'kibana'],
catalogue: ['canvas'],
savedObject: {
all: [],
read: ['index-pattern', 'canvas-workpad', 'canvas-element'],

View file

@ -43,6 +43,7 @@ export class EndpointPlugin
app: ['endpoint', 'kibana'],
privileges: {
all: {
app: ['endpoint', 'kibana'],
api: ['resolver'],
savedObject: {
all: [],
@ -51,6 +52,7 @@ export class EndpointPlugin
ui: ['save'],
},
read: {
app: ['endpoint', 'kibana'],
api: [],
savedObject: {
all: [],

View file

@ -4,16 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FeatureKibanaPrivileges, FeatureKibanaPrivilegesSet } from './feature_kibana_privileges';
import { RecursiveReadonly } from '@kbn/utility-types';
import { FeatureKibanaPrivileges } from './feature_kibana_privileges';
import { SubFeatureConfig, SubFeature } from './sub_feature';
/**
* Interface for registering a feature.
* Feature registration allows plugins to hide their applications with spaces,
* and secure access when configured for security.
*/
export interface Feature<
TPrivileges extends Partial<FeatureKibanaPrivilegesSet> = FeatureKibanaPrivilegesSet
> {
export interface FeatureConfig {
/**
* Unique identifier for this feature.
* This identifier is also used when generating UI Capabilities.
@ -28,6 +28,11 @@ export interface Feature<
*/
name: string;
/**
* An ordinal used to sort features relative to one another for display.
*/
order?: number;
/**
* Whether or not this feature should be excluded from the base privileges.
* This is primarily helpful when migrating applications with a "legacy" privileges model
@ -98,7 +103,15 @@ export interface Feature<
* ```
* @see FeatureKibanaPrivileges
*/
privileges: TPrivileges;
privileges: {
all: FeatureKibanaPrivileges;
read: FeatureKibanaPrivileges;
} | null;
/**
* Optional sub-feature privilege definitions. This can only be specified if `privileges` are are also defined.
*/
subFeatures?: SubFeatureConfig[];
/**
* Optional message to display on the Role Management screen when configuring permissions for this feature.
@ -114,7 +127,64 @@ export interface Feature<
};
}
export type FeatureWithAllOrReadPrivileges = Feature<{
all?: FeatureKibanaPrivileges;
read?: FeatureKibanaPrivileges;
}>;
export class Feature {
public readonly subFeatures: SubFeature[];
constructor(protected readonly config: RecursiveReadonly<FeatureConfig>) {
this.subFeatures = (config.subFeatures ?? []).map(
subFeatureConfig => new SubFeature(subFeatureConfig)
);
}
public get id() {
return this.config.id;
}
public get name() {
return this.config.name;
}
public get order() {
return this.config.order;
}
public get navLinkId() {
return this.config.navLinkId;
}
public get app() {
return this.config.app;
}
public get catalogue() {
return this.config.catalogue;
}
public get management() {
return this.config.management;
}
public get icon() {
return this.config.icon;
}
public get validLicenses() {
return this.config.validLicenses;
}
public get privileges() {
return this.config.privileges;
}
public get excludeFromBasePrivileges() {
return this.config.excludeFromBasePrivileges ?? false;
}
public get reserved() {
return this.config.reserved;
}
public toRaw() {
return { ...this.config } as FeatureConfig;
}
}

View file

@ -123,5 +123,3 @@ export interface FeatureKibanaPrivileges {
*/
ui: string[];
}
export type FeatureKibanaPrivilegesSet = Record<string, FeatureKibanaPrivileges>;

View file

@ -5,4 +5,11 @@
*/
export { FeatureKibanaPrivileges } from './feature_kibana_privileges';
export * from './feature';
export { Feature, FeatureConfig } from './feature';
export {
SubFeature,
SubFeatureConfig,
SubFeaturePrivilegeConfig,
SubFeaturePrivilegeGroupConfig,
SubFeaturePrivilegeGroupType,
} from './sub_feature';

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RecursiveReadonly } from '@kbn/utility-types';
import { FeatureKibanaPrivileges } from './feature_kibana_privileges';
/**
* Configuration for a sub-feature.
*/
export interface SubFeatureConfig {
/** Display name for this sub-feature */
name: string;
/** Collection of privilege groups */
privilegeGroups: SubFeaturePrivilegeGroupConfig[];
}
/**
* The type of privilege group.
* - `mutually_exclusive`::
* Users will be able to select at most one privilege within this group.
* Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All)
* - `independent`::
* Users will be able to select any combination of privileges within this group.
*/
export type SubFeaturePrivilegeGroupType = 'mutually_exclusive' | 'independent';
/**
* Configuration for a sub-feature privilege group.
*/
export interface SubFeaturePrivilegeGroupConfig {
/**
* The type of privilege group.
* - `mutually_exclusive`::
* Users will be able to select at most one privilege within this group.
* Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All)
* - `independent`::
* Users will be able to select any combination of privileges within this group.
*/
groupType: SubFeaturePrivilegeGroupType;
/**
* The privileges which belong to this group.
*/
privileges: SubFeaturePrivilegeConfig[];
}
/**
* Configuration for a sub-feature privilege.
*/
export interface SubFeaturePrivilegeConfig
extends Omit<FeatureKibanaPrivileges, 'excludeFromBasePrivileges'> {
/**
* Identifier for this privilege. Must be unique across all other privileges within a feature.
*/
id: string;
/**
* The display name for this privilege.
*/
name: string;
/**
* Denotes which Primary Feature Privilege this sub-feature privilege should be included in.
* `read` is also included in `all` automatically.
*/
includeIn: 'all' | 'read' | 'none';
}
export class SubFeature {
constructor(protected readonly config: RecursiveReadonly<SubFeatureConfig>) {}
public get name() {
return this.config.name;
}
public get privilegeGroups() {
return this.config.privilegeGroups;
}
public toRaw() {
return { ...this.config };
}
}

View file

@ -4,5 +4,5 @@
"kibanaVersion": "kibana",
"optionalPlugins": ["timelion"],
"server": true,
"ui": false
"ui": true
}

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { coreMock } from 'src/core/public/mocks';
import { FeaturesAPIClient } from './features_api_client';
describe('Features API Client', () => {
describe('#getFeatures', () => {
it('returns an array of Features', async () => {
const rawFeatures = [
{
id: 'feature-a',
},
{
id: 'feature-b',
},
{
id: 'feature-c',
},
{
id: 'feature-d',
},
{
id: 'feature-e',
},
];
const coreSetup = coreMock.createSetup();
coreSetup.http.get.mockResolvedValue(rawFeatures);
const client = new FeaturesAPIClient(coreSetup.http);
const result = await client.getFeatures();
expect(result.map(f => f.id)).toEqual([
'feature-a',
'feature-b',
'feature-c',
'feature-d',
'feature-e',
]);
});
});
});

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'src/core/public';
import { FeatureConfig, Feature } from '.';
export class FeaturesAPIClient {
constructor(private readonly http: HttpSetup) {}
public async getFeatures() {
const features = await this.http.get<FeatureConfig[]>('/api/features');
return features.map(config => new Feature(config));
}
}

View file

@ -4,4 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common';
import { PluginInitializer } from 'src/core/public';
import { FeaturesPlugin, FeaturesPluginSetup, FeaturesPluginStart } from './plugin';
export {
Feature,
FeatureConfig,
FeatureKibanaPrivileges,
SubFeatureConfig,
SubFeaturePrivilegeConfig,
} from '../common';
export { FeaturesPluginSetup, FeaturesPluginStart } from './plugin';
export const plugin: PluginInitializer<FeaturesPluginSetup, FeaturesPluginStart> = () =>
new FeaturesPlugin();

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FeaturesPluginStart } from './plugin';
const createStart = (): jest.Mocked<FeaturesPluginStart> => {
return {
getFeatures: jest.fn(),
};
};
export const featuresPluginMock = {
createStart,
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FeaturesPlugin } from './plugin';
import { coreMock, httpServiceMock } from 'src/core/public/mocks';
jest.mock('./features_api_client', () => {
const instance = {
getFeatures: jest.fn(),
};
return {
FeaturesAPIClient: jest.fn().mockImplementation(() => instance),
};
});
import { FeaturesAPIClient } from './features_api_client';
describe('Features Plugin', () => {
describe('#setup', () => {
it('returns expected public contract', () => {
const plugin = new FeaturesPlugin();
expect(plugin.setup(coreMock.createSetup())).toMatchInlineSnapshot(`undefined`);
});
});
describe('#start', () => {
it('returns expected public contract', () => {
const plugin = new FeaturesPlugin();
plugin.setup(coreMock.createSetup());
expect(plugin.start()).toMatchInlineSnapshot(`
Object {
"getFeatures": [Function],
}
`);
});
it('#getFeatures calls the underlying FeaturesAPIClient', () => {
const plugin = new FeaturesPlugin();
const apiClient = new FeaturesAPIClient(httpServiceMock.createSetupContract());
plugin.setup(coreMock.createSetup());
const start = plugin.start();
start.getFeatures();
expect(apiClient.getFeatures).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Plugin, CoreSetup } from 'src/core/public';
import { FeaturesAPIClient } from './features_api_client';
export class FeaturesPlugin implements Plugin<FeaturesPluginSetup, FeaturesPluginStart> {
private apiClient?: FeaturesAPIClient;
public setup(core: CoreSetup) {
this.apiClient = new FeaturesAPIClient(core.http);
}
public start() {
return {
getFeatures: () => this.apiClient!.getFeatures(),
};
}
public stop() {}
}
export type FeaturesPluginSetup = ReturnType<FeaturesPlugin['setup']>;
export type FeaturesPluginStart = ReturnType<FeaturesPlugin['start']>;

View file

@ -0,0 +1,458 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`buildOSSFeatures returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"app": Array [
"kibana",
],
"catalogue": Array [
"advanced_settings",
],
"management": Object {
"kibana": Array [
"settings",
],
},
"savedObject": Object {
"all": Array [
"config",
],
"read": Array [],
},
"ui": Array [
"save",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"app": Array [
"kibana",
],
"catalogue": Array [
"advanced_settings",
],
"management": Object {
"kibana": Array [
"settings",
],
},
"savedObject": Object {
"all": Array [],
"read": Array [],
},
"ui": Array [],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the dashboard feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [],
"app": Array [
"kibana",
],
"catalogue": Array [
"dashboard",
],
"management": Object {},
"savedObject": Object {
"all": Array [
"dashboard",
"url",
"query",
],
"read": Array [
"index-pattern",
"search",
"visualization",
"timelion-sheet",
"canvas-workpad",
"lens",
"map",
],
},
"ui": Array [
"createNew",
"show",
"showWriteControls",
"saveQuery",
"createShortUrl",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"app": Array [
"kibana",
],
"catalogue": Array [
"dashboard",
],
"savedObject": Object {
"all": Array [],
"read": Array [
"index-pattern",
"search",
"visualization",
"timelion-sheet",
"canvas-workpad",
"map",
"dashboard",
"query",
],
},
"ui": Array [
"show",
],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [
"console",
],
"app": Array [
"kibana",
],
"catalogue": Array [
"console",
"searchprofiler",
"grokdebugger",
],
"savedObject": Object {
"all": Array [],
"read": Array [],
},
"ui": Array [
"show",
"save",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"api": Array [
"console",
],
"app": Array [
"kibana",
],
"catalogue": Array [
"console",
"searchprofiler",
"grokdebugger",
],
"savedObject": Object {
"all": Array [],
"read": Array [],
},
"ui": Array [
"show",
],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the discover feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [],
"app": Array [
"kibana",
],
"catalogue": Array [
"discover",
],
"management": Object {},
"savedObject": Object {
"all": Array [
"search",
"query",
"url",
],
"read": Array [
"index-pattern",
],
},
"ui": Array [
"show",
"save",
"saveQuery",
"createShortUrl",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"app": Array [
"kibana",
],
"catalogue": Array [
"discover",
],
"savedObject": Object {
"all": Array [],
"read": Array [
"index-pattern",
"search",
"query",
],
},
"ui": Array [
"show",
],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"app": Array [
"kibana",
],
"catalogue": Array [
"index_patterns",
],
"management": Object {
"kibana": Array [
"index_patterns",
],
},
"savedObject": Object {
"all": Array [
"index-pattern",
],
"read": Array [],
},
"ui": Array [
"save",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"app": Array [
"kibana",
],
"catalogue": Array [
"index_patterns",
],
"management": Object {
"kibana": Array [
"index_patterns",
],
},
"savedObject": Object {
"all": Array [],
"read": Array [
"index-pattern",
],
},
"ui": Array [],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [
"copySavedObjectsToSpaces",
],
"app": Array [
"kibana",
],
"catalogue": Array [
"saved_objects",
],
"management": Object {
"kibana": Array [
"objects",
],
},
"savedObject": Object {
"all": Array [
"foo",
"bar",
],
"read": Array [],
},
"ui": Array [
"read",
"edit",
"delete",
"copyIntoSpace",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"api": Array [
"copySavedObjectsToSpaces",
],
"app": Array [
"kibana",
],
"catalogue": Array [
"saved_objects",
],
"management": Object {
"kibana": Array [
"objects",
],
},
"savedObject": Object {
"all": Array [],
"read": Array [
"foo",
"bar",
],
},
"ui": Array [
"read",
],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the timelion feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"app": Array [
"timelion",
"kibana",
],
"catalogue": Array [
"timelion",
],
"savedObject": Object {
"all": Array [
"timelion-sheet",
],
"read": Array [
"index-pattern",
],
},
"ui": Array [
"save",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"app": Array [
"timelion",
"kibana",
],
"catalogue": Array [
"timelion",
],
"savedObject": Object {
"all": Array [],
"read": Array [
"index-pattern",
"timelion-sheet",
],
},
"ui": Array [],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures returns the visualize feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [],
"app": Array [
"kibana",
"lens",
],
"catalogue": Array [
"visualize",
],
"management": Object {},
"savedObject": Object {
"all": Array [
"visualization",
"query",
"lens",
"url",
],
"read": Array [
"index-pattern",
"search",
],
},
"ui": Array [
"show",
"delete",
"save",
"saveQuery",
"createShortUrl",
],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"app": Array [
"kibana",
"lens",
],
"catalogue": Array [
"visualize",
],
"savedObject": Object {
"all": Array [],
"read": Array [
"index-pattern",
"search",
"visualization",
"query",
"lens",
],
},
"ui": Array [
"show",
],
},
"privilegeId": "read",
},
]
`;

View file

@ -5,15 +5,15 @@
*/
import { FeatureRegistry } from './feature_registry';
import { Feature } from '../common/feature';
import { FeatureConfig } from '../common/feature';
describe('FeatureRegistry', () => {
it('allows a minimal feature to be registered', () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: {},
privileges: null,
};
const featureRegistry = new FeatureRegistry();
@ -22,18 +22,18 @@ describe('FeatureRegistry', () => {
expect(result).toHaveLength(1);
// Should be the equal, but not the same instance (i.e., a defensive copy)
expect(result[0]).not.toBe(feature);
expect(result[0]).toEqual(feature);
expect(result[0].toRaw()).not.toBe(feature);
expect(result[0].toRaw()).toEqual(feature);
});
it('allows a complex feature to be registered', () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
excludeFromBasePrivileges: true,
icon: 'addDataApp',
navLinkId: 'someNavLink',
app: ['app1', 'app2'],
app: ['app1'],
validLicenses: ['standard', 'basic', 'gold', 'platinum'],
catalogue: ['foo'],
management: {
@ -53,7 +53,61 @@ describe('FeatureRegistry', () => {
api: ['someApiEndpointTag', 'anotherEndpointTag'],
ui: ['allowsFoo', 'showBar', 'showBaz'],
},
read: {
savedObject: {
all: [],
read: ['config', 'url'],
},
ui: [],
},
},
subFeatures: [
{
name: 'sub-feature-1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'foo',
name: 'foo',
includeIn: 'read',
savedObject: {
all: [],
read: [],
},
ui: [],
},
],
},
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'bar',
name: 'bar',
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
ui: [],
},
{
id: 'baz',
name: 'baz',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: [],
},
],
},
],
},
],
privilegesTooltip: 'some fancy tooltip',
reserved: {
privilege: {
@ -79,36 +133,61 @@ describe('FeatureRegistry', () => {
expect(result).toHaveLength(1);
// Should be the equal, but not the same instance (i.e., a defensive copy)
expect(result[0]).not.toBe(feature);
expect(result[0]).toEqual(feature);
expect(result[0].toRaw()).not.toBe(feature);
expect(result[0].toRaw()).toEqual(feature);
});
it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => {
const feature: Feature = {
it(`requires a value for privileges`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: {
all: {
ui: [],
savedObject: {
all: [],
read: [],
},
} as any;
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"child \\"privileges\\" fails because [\\"privileges\\" is required]"`
);
});
it(`does not allow sub-features to be registered when no primary privileges are not registered`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: null,
subFeatures: [
{
name: 'my sub feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'my-sub-priv',
name: 'my sub priv',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: [],
},
],
},
],
},
},
],
};
const featureRegistry = new FeatureRegistry();
featureRegistry.register(feature);
const result = featureRegistry.getAll();
const allPrivilege = result[0].privileges.all;
expect(allPrivilege.savedObject.all).toEqual(['telemetry']);
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"`
);
});
it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => {
const feature: Feature = {
it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
@ -134,18 +213,55 @@ describe('FeatureRegistry', () => {
featureRegistry.register(feature);
const result = featureRegistry.getAll();
const allPrivilege = result[0].privileges.all;
const readPrivilege = result[0].privileges.read;
expect(allPrivilege.savedObject.read).toEqual(['config', 'url']);
expect(readPrivilege.savedObject.read).toEqual(['config', 'url']);
expect(result[0].privileges).toHaveProperty('all');
expect(result[0].privileges).toHaveProperty('read');
const allPrivilege = result[0].privileges?.all;
expect(allPrivilege?.savedObject.all).toEqual(['telemetry']);
});
it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => {
const feature: Feature = {
it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: {},
privileges: {
all: {
ui: [],
savedObject: {
all: [],
read: [],
},
},
read: {
ui: [],
savedObject: {
all: [],
read: [],
},
},
},
};
const featureRegistry = new FeatureRegistry();
featureRegistry.register(feature);
const result = featureRegistry.getAll();
expect(result[0].privileges).toHaveProperty('all');
expect(result[0].privileges).toHaveProperty('read');
const allPrivilege = result[0].privileges?.all;
const readPrivilege = result[0].privileges?.read;
expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']);
expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']);
});
it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: null,
reserved: {
description: 'foo',
privilege: {
@ -168,7 +284,7 @@ describe('FeatureRegistry', () => {
});
it(`does not duplicate the automatic grants if specified on the incoming feature`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
@ -194,26 +310,29 @@ describe('FeatureRegistry', () => {
featureRegistry.register(feature);
const result = featureRegistry.getAll();
const allPrivilege = result[0].privileges.all;
const readPrivilege = result[0].privileges.read;
expect(allPrivilege.savedObject.all).toEqual(['telemetry']);
expect(allPrivilege.savedObject.read).toEqual(['config', 'url']);
expect(readPrivilege.savedObject.read).toEqual(['config', 'url']);
expect(result[0].privileges).toHaveProperty('all');
expect(result[0].privileges).toHaveProperty('read');
const allPrivilege = result[0].privileges!.all;
const readPrivilege = result[0].privileges!.read;
expect(allPrivilege?.savedObject.all).toEqual(['telemetry']);
expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']);
expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']);
});
it(`does not allow duplicate features to be registered`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: {},
privileges: null,
};
const duplicateFeature: Feature = {
const duplicateFeature: FeatureConfig = {
id: 'test-feature',
name: 'Duplicate Test Feature',
app: [],
privileges: {},
privileges: null,
};
const featureRegistry = new FeatureRegistry();
@ -233,7 +352,7 @@ describe('FeatureRegistry', () => {
name: 'some feature',
navLinkId: prohibitedChars,
app: [],
privileges: {},
privileges: null,
})
).toThrowErrorMatchingSnapshot();
});
@ -248,7 +367,7 @@ describe('FeatureRegistry', () => {
kibana: [prohibitedChars],
},
app: [],
privileges: {},
privileges: null,
})
).toThrowErrorMatchingSnapshot();
});
@ -261,7 +380,7 @@ describe('FeatureRegistry', () => {
name: 'some feature',
catalogue: [prohibitedChars],
app: [],
privileges: {},
privileges: null,
})
).toThrowErrorMatchingSnapshot();
});
@ -275,19 +394,20 @@ describe('FeatureRegistry', () => {
id: prohibitedId,
name: 'some feature',
app: [],
privileges: {},
privileges: null,
})
).toThrowErrorMatchingSnapshot();
});
});
it('prevents features from being registered with invalid privilege names', () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: ['app1', 'app2'],
privileges: {
foo: {
name: 'Foo',
app: ['app1', 'app2'],
savedObject: {
all: ['config', 'space', 'etc'],
@ -296,7 +416,7 @@ describe('FeatureRegistry', () => {
api: ['someApiEndpointTag', 'anotherEndpointTag'],
ui: ['allowsFoo', 'showBar', 'showBaz'],
},
},
} as any,
};
const featureRegistry = new FeatureRegistry();
@ -306,7 +426,7 @@ describe('FeatureRegistry', () => {
});
it(`prevents privileges from specifying app entries that don't exist at the root level`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: ['bar'],
@ -319,6 +439,14 @@ describe('FeatureRegistry', () => {
ui: [],
app: ['foo', 'bar', 'baz'],
},
read: {
savedObject: {
all: [],
read: [],
},
ui: [],
app: ['foo', 'bar', 'baz'],
},
},
};
@ -329,12 +457,67 @@ describe('FeatureRegistry', () => {
);
});
it(`prevents features from specifying app entries that don't exist at the privilege level`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: ['foo', 'bar', 'baz'],
privileges: {
all: {
savedObject: {
all: [],
read: [],
},
ui: [],
app: ['bar'],
},
read: {
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
subFeatures: [
{
name: 'my sub feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'cool-sub-feature-privilege',
name: 'cool privilege',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: [],
app: ['foo'],
},
],
},
],
},
],
};
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"Feature test-feature specifies app entries which are not granted to any privileges: baz"`
);
});
it(`prevents reserved privileges from specifying app entries that don't exist at the root level`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: ['bar'],
privileges: {},
privileges: null,
reserved: {
description: 'something',
privilege: {
@ -355,8 +538,34 @@ describe('FeatureRegistry', () => {
);
});
it(`prevents features from specifying app entries that don't exist at the reserved privilege level`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: ['foo', 'bar', 'baz'],
privileges: null,
reserved: {
description: 'something',
privilege: {
savedObject: {
all: [],
read: [],
},
ui: [],
app: ['foo', 'bar'],
},
},
};
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"Feature test-feature specifies app entries which are not granted to any privileges: baz"`
);
});
it(`prevents privileges from specifying catalogue entries that don't exist at the root level`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
@ -371,6 +580,15 @@ describe('FeatureRegistry', () => {
ui: [],
app: [],
},
read: {
catalogue: ['foo', 'bar', 'baz'],
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
};
@ -381,13 +599,71 @@ describe('FeatureRegistry', () => {
);
});
it(`prevents features from specifying catalogue entries that don't exist at the privilege level`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
catalogue: ['foo', 'bar', 'baz'],
privileges: {
all: {
catalogue: ['foo'],
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
read: {
catalogue: ['foo'],
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
subFeatures: [
{
name: 'my sub feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'cool-sub-feature-privilege',
name: 'cool privilege',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: [],
catalogue: ['bar'],
},
],
},
],
},
],
};
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"`
);
});
it(`prevents reserved privileges from specifying catalogue entries that don't exist at the root level`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
catalogue: ['bar'],
privileges: {},
privileges: null,
reserved: {
description: 'something',
privilege: {
@ -409,8 +685,36 @@ describe('FeatureRegistry', () => {
);
});
it(`prevents features from specifying catalogue entries that don't exist at the reserved privilege level`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
catalogue: ['foo', 'bar', 'baz'],
privileges: null,
reserved: {
description: 'something',
privilege: {
catalogue: ['foo', 'bar'],
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
};
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"`
);
});
it(`prevents privileges from specifying management sections that don't exist at the root level`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
@ -431,6 +735,18 @@ describe('FeatureRegistry', () => {
ui: [],
app: [],
},
read: {
catalogue: ['bar'],
management: {
elasticsearch: ['hey'],
},
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
};
@ -441,8 +757,79 @@ describe('FeatureRegistry', () => {
);
});
it(`prevents features from specifying management sections that don't exist at the privilege level`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
catalogue: ['bar'],
management: {
kibana: ['hey'],
elasticsearch: ['hey', 'there'],
},
privileges: {
all: {
catalogue: ['bar'],
management: {
elasticsearch: ['hey'],
},
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
read: {
catalogue: ['bar'],
management: {
elasticsearch: ['hey'],
},
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
subFeatures: [
{
name: 'my sub feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'cool-sub-feature-privilege',
name: 'cool privilege',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: [],
management: {
kibana: ['hey'],
elasticsearch: ['hey'],
},
},
],
},
],
},
],
};
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"`
);
});
it(`prevents reserved privileges from specifying management entries that don't exist at the root level`, () => {
const feature: Feature = {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
@ -450,7 +837,7 @@ describe('FeatureRegistry', () => {
management: {
kibana: ['hey'],
},
privileges: {},
privileges: null,
reserved: {
description: 'something',
privilege: {
@ -475,18 +862,52 @@ describe('FeatureRegistry', () => {
);
});
it('cannot register feature after getAll has been called', () => {
const feature1: Feature = {
it(`prevents features from specifying management entries that don't exist at the reserved privilege level`, () => {
const feature: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: {},
catalogue: ['bar'],
management: {
kibana: ['hey', 'hey-there'],
},
privileges: null,
reserved: {
description: 'something',
privilege: {
catalogue: ['bar'],
management: {
kibana: ['hey-there'],
},
savedObject: {
all: [],
read: [],
},
ui: [],
app: [],
},
},
};
const feature2: Feature = {
const featureRegistry = new FeatureRegistry();
expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot(
`"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"`
);
});
it('cannot register feature after getAll has been called', () => {
const feature1: FeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
app: [],
privileges: null,
};
const feature2: FeatureConfig = {
id: 'test-feature-2',
name: 'Test Feature 2',
app: [],
privileges: {},
privileges: null,
};
const featureRegistry = new FeatureRegistry();

View file

@ -5,14 +5,14 @@
*/
import { cloneDeep, uniq } from 'lodash';
import { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common';
import { FeatureConfig, Feature, FeatureKibanaPrivileges } from '../common';
import { validateFeature } from './feature_schema';
export class FeatureRegistry {
private locked = false;
private features: Record<string, Feature> = {};
private features: Record<string, FeatureConfig> = {};
public register(feature: FeatureWithAllOrReadPrivileges) {
public register(feature: FeatureConfig) {
if (this.locked) {
throw new Error(
`Features are locked, can't register new features. Attempt to register ${feature.id} failed.`
@ -25,20 +25,21 @@ export class FeatureRegistry {
throw new Error(`Feature with id ${feature.id} is already registered.`);
}
const featureCopy: Feature = cloneDeep(feature as Feature);
const featureCopy = cloneDeep(feature);
this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy as Feature);
this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy);
}
public getAll(): Feature[] {
this.locked = true;
return cloneDeep(Object.values(this.features));
return Object.values(this.features).map(featureConfig => new Feature(featureConfig));
}
}
function applyAutomaticPrivilegeGrants(feature: Feature): Feature {
const { all: allPrivilege, read: readPrivilege } = feature.privileges;
const reservedPrivilege = feature.reserved ? feature.reserved.privilege : null;
function applyAutomaticPrivilegeGrants(feature: FeatureConfig): FeatureConfig {
const allPrivilege = feature.privileges?.all;
const readPrivilege = feature.privileges?.read;
const reservedPrivilege = feature.reserved?.privilege;
applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege);
applyAutomaticReadPrivilegeGrants(readPrivilege);
@ -46,7 +47,9 @@ function applyAutomaticPrivilegeGrants(feature: Feature): Feature {
return feature;
}
function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array<FeatureKibanaPrivileges | null>) {
function applyAutomaticAllPrivilegeGrants(
...allPrivileges: Array<FeatureKibanaPrivileges | undefined>
) {
allPrivileges.forEach(allPrivilege => {
if (allPrivilege) {
allPrivilege.savedObject.all = uniq([...allPrivilege.savedObject.all, 'telemetry']);
@ -56,7 +59,7 @@ function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array<FeatureKibanaP
}
function applyAutomaticReadPrivilegeGrants(
...readPrivileges: Array<FeatureKibanaPrivileges | null>
...readPrivileges: Array<FeatureKibanaPrivileges | undefined>
) {
readPrivileges.forEach(readPrivilege => {
if (readPrivilege) {

View file

@ -8,13 +8,15 @@ import Joi from 'joi';
import { difference } from 'lodash';
import { Capabilities as UICapabilities } from '../../../../src/core/server';
import { FeatureWithAllOrReadPrivileges } from '../common/feature';
import { FeatureConfig } from '../common/feature';
import { FeatureKibanaPrivileges } from '.';
// Each feature gets its own property on the UICapabilities object,
// but that object has a few built-in properties which should not be overwritten.
const prohibitedFeatureIds: Array<keyof UICapabilities> = ['catalogue', 'management', 'navLinks'];
const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/;
const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/;
const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/;
export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/;
@ -43,12 +45,52 @@ const privilegeSchema = Joi.object({
.required(),
});
const subFeaturePrivilegeSchema = Joi.object({
id: Joi.string()
.regex(subFeaturePrivilegePartRegex)
.required(),
name: Joi.string().required(),
includeIn: Joi.string()
.allow('all', 'read', 'none')
.required(),
management: managementSchema,
catalogue: catalogueSchema,
api: Joi.array().items(Joi.string()),
app: Joi.array().items(Joi.string()),
savedObject: Joi.object({
all: Joi.array()
.items(Joi.string())
.required(),
read: Joi.array()
.items(Joi.string())
.required(),
}).required(),
ui: Joi.array()
.items(Joi.string().regex(uiCapabilitiesRegex))
.required(),
});
const subFeatureSchema = Joi.object({
name: Joi.string().required(),
privilegeGroups: Joi.array().items(
Joi.object({
groupType: Joi.string()
.valid('mutually_exclusive', 'independent')
.required(),
privileges: Joi.array()
.items(subFeaturePrivilegeSchema)
.min(1),
})
),
});
const schema = Joi.object({
id: Joi.string()
.regex(featurePrivilegePartRegex)
.invalid(...prohibitedFeatureIds)
.required(),
name: Joi.string().required(),
order: Joi.number(),
excludeFromBasePrivileges: Joi.boolean(),
validLicenses: Joi.array().items(
Joi.string().valid('basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial')
@ -64,7 +106,16 @@ const schema = Joi.object({
privileges: Joi.object({
all: privilegeSchema,
read: privilegeSchema,
}).required(),
})
.allow(null)
.required(),
subFeatures: Joi.when('privileges', {
is: null,
then: Joi.array()
.items(subFeatureSchema)
.max(0),
otherwise: Joi.array().items(subFeatureSchema),
}),
privilegesTooltip: Joi.string(),
reserved: Joi.object({
privilege: privilegeSchema.required(),
@ -72,7 +123,7 @@ const schema = Joi.object({
}),
});
export function validateFeature(feature: FeatureWithAllOrReadPrivileges) {
export function validateFeature(feature: FeatureConfig) {
const validateResult = Joi.validate(feature, schema);
if (validateResult.error) {
throw validateResult.error;
@ -80,17 +131,21 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) {
// the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid.
const { app = [], management = {}, catalogue = [] } = feature;
const privilegeEntries = [...Object.entries(feature.privileges)];
if (feature.reserved) {
privilegeEntries.push(['reserved', feature.reserved.privilege]);
}
const unseenApps = new Set(app);
privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => {
if (!privilegeDefinition) {
throw new Error('Privilege definition may not be null or undefined');
}
const managementSets = Object.entries(management).map(entry => [
entry[0],
new Set(entry[1]),
]) as Array<[string, Set<string>]>;
const unknownAppEntries = difference(privilegeDefinition.app || [], app);
const unseenManagement = new Map<string, Set<string>>(managementSets);
const unseenCatalogue = new Set(catalogue);
function validateAppEntry(privilegeId: string, entry: string[] = []) {
entry.forEach(privilegeApp => unseenApps.delete(privilegeApp));
const unknownAppEntries = difference(entry, app);
if (unknownAppEntries.length > 0) {
throw new Error(
`Feature privilege ${
@ -98,8 +153,12 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) {
}.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}`
);
}
}
const unknownCatalogueEntries = difference(privilegeDefinition.catalogue || [], catalogue);
function validateCatalogueEntry(privilegeId: string, entry: string[] = []) {
entry.forEach(privilegeCatalogue => unseenCatalogue.delete(privilegeCatalogue));
const unknownCatalogueEntries = difference(entry || [], catalogue);
if (unknownCatalogueEntries.length > 0) {
throw new Error(
`Feature privilege ${
@ -107,27 +166,113 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) {
}.${privilegeId} has unknown catalogue entries: ${unknownCatalogueEntries.join(', ')}`
);
}
}
Object.entries(privilegeDefinition.management || {}).forEach(
([managementSectionId, managementEntry]) => {
if (!management[managementSectionId]) {
throw new Error(
`Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}`
);
}
const unknownSectionEntries = difference(managementEntry, management[managementSectionId]);
if (unknownSectionEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join(
', '
)}`
);
}
function validateManagementEntry(
privilegeId: string,
managementEntry: Record<string, string[]> = {}
) {
Object.entries(managementEntry).forEach(([managementSectionId, managementSectionEntry]) => {
if (unseenManagement.has(managementSectionId)) {
managementSectionEntry.forEach(entry => {
unseenManagement.get(managementSectionId)!.delete(entry);
if (unseenManagement.get(managementSectionId)?.size === 0) {
unseenManagement.delete(managementSectionId);
}
});
}
);
if (!management[managementSectionId]) {
throw new Error(
`Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}`
);
}
const unknownSectionEntries = difference(
managementSectionEntry,
management[managementSectionId]
);
if (unknownSectionEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join(
', '
)}`
);
}
});
}
const privilegeEntries: Array<[string, FeatureKibanaPrivileges]> = [];
if (feature.privileges) {
privilegeEntries.push(...Object.entries(feature.privileges));
}
if (feature.reserved) {
privilegeEntries.push(['reserved', feature.reserved.privilege]);
}
if (privilegeEntries.length === 0) {
return;
}
privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => {
if (!privilegeDefinition) {
throw new Error('Privilege definition may not be null or undefined');
}
validateAppEntry(privilegeId, privilegeDefinition.app);
validateCatalogueEntry(privilegeId, privilegeDefinition.catalogue);
validateManagementEntry(privilegeId, privilegeDefinition.management);
});
const subFeatureEntries = feature.subFeatures ?? [];
subFeatureEntries.forEach(subFeature => {
subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => {
subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => {
validateAppEntry(subFeaturePrivilege.id, subFeaturePrivilege.app);
validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue);
validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management);
});
});
});
if (unseenApps.size > 0) {
throw new Error(
`Feature ${
feature.id
} specifies app entries which are not granted to any privileges: ${Array.from(
unseenApps.values()
).join(',')}`
);
}
if (unseenCatalogue.size > 0) {
throw new Error(
`Feature ${
feature.id
} specifies catalogue entries which are not granted to any privileges: ${Array.from(
unseenCatalogue.values()
).join(',')}`
);
}
if (unseenManagement.size > 0) {
const ungrantedManagement = Array.from(unseenManagement.entries()).reduce((acc, entry) => {
const values = Array.from(entry[1].values()).map(
managementPage => `${entry[0]}.${managementPage}`
);
return [...acc, ...values];
}, [] as string[]);
throw new Error(
`Feature ${
feature.id
} specifies management entries which are not granted to any privileges: ${ungrantedManagement.join(
','
)}`
);
}
}

View file

@ -13,7 +13,7 @@ import { Plugin } from './plugin';
// run-time contracts.
export { uiCapabilitiesRegex } from './feature_schema';
export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common';
export { Feature, FeatureConfig, FeatureKibanaPrivileges } from '../common';
export { PluginSetupContract, PluginStartContract } from './plugin';
export const plugin = (initializerContext: PluginInitializerContext) =>

View file

@ -5,6 +5,8 @@
*/
import { buildOSSFeatures } from './oss_features';
import { featurePrivilegeIterator } from '../../security/server/authorization';
import { Feature } from '.';
describe('buildOSSFeatures', () => {
it('returns features including timelion', () => {
@ -39,4 +41,17 @@ Array [
]
`);
});
const features = buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true });
features.forEach(featureConfig => {
it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => {
const privileges = [];
for (const featurePrivilege of featurePrivilegeIterator(new Feature(featureConfig), {
augmentWithSubFeaturePrivileges: true,
})) {
privileges.push(featurePrivilege);
}
expect(privileges).toMatchSnapshot();
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { Feature } from '../common/feature';
import { FeatureConfig } from '../common/feature';
export interface BuildOSSFeaturesParams {
savedObjectTypes: string[];
@ -18,19 +18,24 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
name: i18n.translate('xpack.features.discoverFeatureName', {
defaultMessage: 'Discover',
}),
order: 100,
icon: 'discoverApp',
navLinkId: 'kibana:discover',
app: ['kibana'],
catalogue: ['discover'],
privileges: {
all: {
app: ['kibana'],
catalogue: ['discover'],
savedObject: {
all: ['search', 'url', 'query'],
all: ['search', 'query'],
read: ['index-pattern'],
},
ui: ['show', 'createShortUrl', 'save', 'saveQuery'],
ui: ['show', 'save', 'saveQuery'],
},
read: {
app: ['kibana'],
catalogue: ['discover'],
savedObject: {
all: [],
read: ['index-pattern', 'search', 'query'],
@ -38,25 +43,59 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['show'],
},
},
subFeatures: [
{
name: i18n.translate('xpack.features.ossFeatures.discoverShortUrlSubFeatureName', {
defaultMessage: 'Short URLs',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'url_create',
name: i18n.translate(
'xpack.features.ossFeatures.discoverCreateShortUrlPrivilegeName',
{
defaultMessage: 'Create Short URLs',
}
),
includeIn: 'all',
savedObject: {
all: ['url'],
read: [],
},
ui: ['createShortUrl'],
},
],
},
],
},
],
},
{
id: 'visualize',
name: i18n.translate('xpack.features.visualizeFeatureName', {
defaultMessage: 'Visualize',
}),
order: 200,
icon: 'visualizeApp',
navLinkId: 'kibana:visualize',
app: ['kibana', 'lens'],
catalogue: ['visualize'],
privileges: {
all: {
app: ['kibana', 'lens'],
catalogue: ['visualize'],
savedObject: {
all: ['visualization', 'url', 'query', 'lens'],
all: ['visualization', 'query', 'lens'],
read: ['index-pattern', 'search'],
},
ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'],
ui: ['show', 'delete', 'save', 'saveQuery'],
},
read: {
app: ['kibana', 'lens'],
catalogue: ['visualize'],
savedObject: {
all: [],
read: ['index-pattern', 'search', 'visualization', 'query', 'lens'],
@ -64,18 +103,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['show'],
},
},
subFeatures: [
{
name: i18n.translate('xpack.features.ossFeatures.visualizeShortUrlSubFeatureName', {
defaultMessage: 'Short URLs',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'url_create',
name: i18n.translate(
'xpack.features.ossFeatures.visualizeCreateShortUrlPrivilegeName',
{
defaultMessage: 'Create Short URLs',
}
),
includeIn: 'all',
savedObject: {
all: ['url'],
read: [],
},
ui: ['createShortUrl'],
},
],
},
],
},
],
},
{
id: 'dashboard',
name: i18n.translate('xpack.features.dashboardFeatureName', {
defaultMessage: 'Dashboard',
}),
order: 300,
icon: 'dashboardApp',
navLinkId: 'kibana:dashboard',
app: ['kibana'],
catalogue: ['dashboard'],
privileges: {
all: {
app: ['kibana'],
catalogue: ['dashboard'],
savedObject: {
all: ['dashboard', 'url', 'query'],
read: [
@ -91,6 +162,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'],
},
read: {
app: ['kibana'],
catalogue: ['dashboard'],
savedObject: {
all: [],
read: [
@ -107,18 +180,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['show'],
},
},
subFeatures: [
{
name: i18n.translate('xpack.features.ossFeatures.dashboardShortUrlSubFeatureName', {
defaultMessage: 'Short URLs',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'url_create',
name: i18n.translate(
'xpack.features.ossFeatures.dashboardCreateShortUrlPrivilegeName',
{
defaultMessage: 'Create Short URLs',
}
),
includeIn: 'all',
savedObject: {
all: ['url'],
read: [],
},
ui: ['createShortUrl'],
},
],
},
],
},
],
},
{
id: 'dev_tools',
name: i18n.translate('xpack.features.devToolsFeatureName', {
defaultMessage: 'Dev Tools',
}),
order: 1300,
icon: 'devToolsApp',
navLinkId: 'kibana:dev_tools',
app: ['kibana'],
catalogue: ['console', 'searchprofiler', 'grokdebugger'],
privileges: {
all: {
app: ['kibana'],
catalogue: ['console', 'searchprofiler', 'grokdebugger'],
api: ['console'],
savedObject: {
all: [],
@ -127,6 +232,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['show', 'save'],
},
read: {
app: ['kibana'],
catalogue: ['console', 'searchprofiler', 'grokdebugger'],
api: ['console'],
savedObject: {
all: [],
@ -145,6 +252,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
name: i18n.translate('xpack.features.advancedSettingsFeatureName', {
defaultMessage: 'Advanced Settings',
}),
order: 1500,
icon: 'advancedSettingsApp',
app: ['kibana'],
catalogue: ['advanced_settings'],
@ -153,6 +261,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
},
privileges: {
all: {
app: ['kibana'],
catalogue: ['advanced_settings'],
management: {
kibana: ['settings'],
},
savedObject: {
all: ['config'],
read: [],
@ -160,6 +273,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['save'],
},
read: {
app: ['kibana'],
catalogue: ['advanced_settings'],
management: {
kibana: ['settings'],
},
savedObject: {
all: [],
read: [],
@ -173,6 +291,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
name: i18n.translate('xpack.features.indexPatternFeatureName', {
defaultMessage: 'Index Pattern Management',
}),
order: 1600,
icon: 'indexPatternApp',
app: ['kibana'],
catalogue: ['index_patterns'],
@ -181,6 +300,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
},
privileges: {
all: {
app: ['kibana'],
catalogue: ['index_patterns'],
management: {
kibana: ['index_patterns'],
},
savedObject: {
all: ['index-pattern'],
read: [],
@ -188,6 +312,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['save'],
},
read: {
app: ['kibana'],
catalogue: ['index_patterns'],
management: {
kibana: ['index_patterns'],
},
savedObject: {
all: [],
read: ['index-pattern'],
@ -201,6 +330,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
name: i18n.translate('xpack.features.savedObjectsManagementFeatureName', {
defaultMessage: 'Saved Objects Management',
}),
order: 1700,
icon: 'savedObjectsApp',
app: ['kibana'],
catalogue: ['saved_objects'],
@ -209,6 +339,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
},
privileges: {
all: {
app: ['kibana'],
catalogue: ['saved_objects'],
management: {
kibana: ['objects'],
},
api: ['copySavedObjectsToSpaces'],
savedObject: {
all: [...savedObjectTypes],
@ -217,6 +352,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
ui: ['read', 'edit', 'delete', 'copyIntoSpace'],
},
read: {
app: ['kibana'],
catalogue: ['saved_objects'],
management: {
kibana: ['objects'],
},
api: ['copySavedObjectsToSpaces'],
savedObject: {
all: [],
@ -227,18 +367,21 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
},
},
...(includeTimelion ? [timelionFeature] : []),
];
] as FeatureConfig[];
};
const timelionFeature: Feature = {
const timelionFeature: FeatureConfig = {
id: 'timelion',
name: 'Timelion',
order: 350,
icon: 'timelionApp',
navLinkId: 'timelion',
app: ['timelion', 'kibana'],
catalogue: ['timelion'],
privileges: {
all: {
app: ['timelion', 'kibana'],
catalogue: ['timelion'],
savedObject: {
all: ['timelion-sheet'],
read: ['index-pattern'],
@ -246,6 +389,8 @@ const timelionFeature: Feature = {
ui: ['save'],
},
read: {
app: ['timelion', 'kibana'],
catalogue: ['timelion'],
savedObject: {
all: [],
read: ['index-pattern', 'timelion-sheet'],

View file

@ -15,7 +15,7 @@ import { deepFreeze } from '../../../../src/core/utils';
import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info';
import { PluginSetupContract as TimelionSetupContract } from '../../../../src/plugins/timelion/server';
import { FeatureRegistry } from './feature_registry';
import { Feature, FeatureWithAllOrReadPrivileges } from '../common/feature';
import { Feature, FeatureConfig } from '../common/feature';
import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features';
import { buildOSSFeatures } from './oss_features';
import { defineRoutes } from './routes';
@ -24,7 +24,7 @@ import { defineRoutes } from './routes';
* Describes public Features plugin contract returned at the `setup` stage.
*/
export interface PluginSetupContract {
registerFeature(feature: FeatureWithAllOrReadPrivileges): void;
registerFeature(feature: FeatureConfig): void;
getFeatures(): Feature[];
getFeaturesUICapabilities(): UICapabilities;
registerLegacyAPI: (legacyAPI: LegacyAPI) => void;

View file

@ -10,6 +10,7 @@ import { defineRoutes } from './index';
import { httpServerMock, httpServiceMock } from '../../../../../src/core/server/mocks';
import { XPackInfoLicense } from '../../../../legacy/plugins/xpack_main/server/lib/xpack_info_license';
import { RequestHandler } from '../../../../../src/core/server';
import { FeatureConfig } from '../../common';
let currentLicenseLevel: string = 'gold';
@ -21,7 +22,23 @@ describe('GET /api/features', () => {
id: 'feature_1',
name: 'Feature 1',
app: [],
privileges: {},
privileges: null,
});
featureRegistry.register({
id: 'feature_2',
name: 'Feature 2',
order: 2,
app: [],
privileges: null,
});
featureRegistry.register({
id: 'feature_3',
name: 'Feature 2',
order: 1,
app: [],
privileges: null,
});
featureRegistry.register({
@ -29,7 +46,7 @@ describe('GET /api/features', () => {
name: 'Licensed Feature',
app: ['bar-app'],
validLicenses: ['gold'],
privileges: {},
privileges: null,
});
const routerMock = httpServiceMock.createRouter();
@ -51,37 +68,33 @@ describe('GET /api/features', () => {
routeHandler = routerMock.get.mock.calls[0][1];
});
it('returns a list of available features', async () => {
it('returns a list of available features, sorted by their configured order', async () => {
const mockResponse = httpServerMock.createResponseFactory();
routeHandler(undefined as any, { query: {} } as any, mockResponse);
expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"body": Array [
Object {
"app": Array [],
"id": "feature_1",
"name": "Feature 1",
"privileges": Object {},
},
Object {
"app": Array [
"bar-app",
],
"id": "licensed_feature",
"name": "Licensed Feature",
"privileges": Object {},
"validLicenses": Array [
"gold",
],
},
],
},
],
]
`);
expect(mockResponse.ok).toHaveBeenCalledTimes(1);
const [call] = mockResponse.ok.mock.calls;
const body = call[0]!.body as FeatureConfig[];
const features = body.map(feature => ({ id: feature.id, order: feature.order }));
expect(features).toEqual([
{
id: 'feature_3',
order: 1,
},
{
id: 'feature_2',
order: 2,
},
{
id: 'feature_1',
order: undefined,
},
{
id: 'licensed_feature',
order: undefined,
},
]);
});
it(`by default does not return features that arent allowed by current license`, async () => {
@ -90,22 +103,26 @@ describe('GET /api/features', () => {
const mockResponse = httpServerMock.createResponseFactory();
routeHandler(undefined as any, { query: {} } as any, mockResponse);
expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"body": Array [
Object {
"app": Array [],
"id": "feature_1",
"name": "Feature 1",
"privileges": Object {},
},
],
},
],
]
`);
expect(mockResponse.ok).toHaveBeenCalledTimes(1);
const [call] = mockResponse.ok.mock.calls;
const body = call[0]!.body as FeatureConfig[];
const features = body.map(feature => ({ id: feature.id, order: feature.order }));
expect(features).toEqual([
{
id: 'feature_3',
order: 1,
},
{
id: 'feature_2',
order: 2,
},
{
id: 'feature_1',
order: undefined,
},
]);
});
it(`ignoreValidLicenses=false does not return features that arent allowed by current license`, async () => {
@ -114,22 +131,26 @@ describe('GET /api/features', () => {
const mockResponse = httpServerMock.createResponseFactory();
routeHandler(undefined as any, { query: { ignoreValidLicenses: false } } as any, mockResponse);
expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"body": Array [
Object {
"app": Array [],
"id": "feature_1",
"name": "Feature 1",
"privileges": Object {},
},
],
},
],
]
`);
expect(mockResponse.ok).toHaveBeenCalledTimes(1);
const [call] = mockResponse.ok.mock.calls;
const body = call[0]!.body as FeatureConfig[];
const features = body.map(feature => ({ id: feature.id, order: feature.order }));
expect(features).toEqual([
{
id: 'feature_3',
order: 1,
},
{
id: 'feature_2',
order: 2,
},
{
id: 'feature_1',
order: undefined,
},
]);
});
it(`ignoreValidLicenses=true returns features that arent allowed by current license`, async () => {
@ -138,32 +159,29 @@ describe('GET /api/features', () => {
const mockResponse = httpServerMock.createResponseFactory();
routeHandler(undefined as any, { query: { ignoreValidLicenses: true } } as any, mockResponse);
expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"body": Array [
Object {
"app": Array [],
"id": "feature_1",
"name": "Feature 1",
"privileges": Object {},
},
Object {
"app": Array [
"bar-app",
],
"id": "licensed_feature",
"name": "Licensed Feature",
"privileges": Object {},
"validLicenses": Array [
"gold",
],
},
],
},
],
]
`);
expect(mockResponse.ok).toHaveBeenCalledTimes(1);
const [call] = mockResponse.ok.mock.calls;
const body = call[0]!.body as FeatureConfig[];
const features = body.map(feature => ({ id: feature.id, order: feature.order }));
expect(features).toEqual([
{
id: 'feature_3',
order: 1,
},
{
id: 'feature_2',
order: 2,
},
{
id: 'feature_1',
order: undefined,
},
{
id: 'licensed_feature',
order: undefined,
},
]);
});
});

View file

@ -31,13 +31,19 @@ export function defineRoutes({ router, featureRegistry, getLegacyAPI }: RouteDef
const allFeatures = featureRegistry.getAll();
return response.ok({
body: allFeatures.filter(
feature =>
request.query.ignoreValidLicenses ||
!feature.validLicenses ||
!feature.validLicenses.length ||
getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses)
),
body: allFeatures
.filter(
feature =>
request.query.ignoreValidLicenses ||
!feature.validLicenses ||
!feature.validLicenses.length ||
getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses)
)
.sort(
(f1, f2) =>
(f1.order ?? Number.MAX_SAFE_INTEGER) - (f2.order ?? Number.MAX_SAFE_INTEGER)
)
.map(feature => feature.toRaw()),
});
}
);

View file

@ -5,17 +5,31 @@
*/
import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features';
import { Feature } from '.';
import { SubFeaturePrivilegeGroupConfig } from '../common';
function createFeaturePrivilege(key: string, capabilities: string[] = []) {
function createFeaturePrivilege(capabilities: string[] = []) {
return {
[key]: {
savedObject: {
all: [],
read: [],
},
app: [],
ui: [...capabilities],
savedObject: {
all: [],
read: [],
},
app: [],
ui: [...capabilities],
};
}
function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = []) {
return {
id: privilegeId,
name: `sub-feature privilege ${privilegeId}`,
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
app: [],
ui: [...capabilities],
};
}
@ -27,14 +41,15 @@ describe('populateUICapabilities', () => {
it('handles features with no registered capabilities', () => {
expect(
uiCapabilitiesForFeatures([
{
new Feature({
id: 'newFeature',
name: 'my new feature',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all'),
all: createFeaturePrivilege(),
read: createFeaturePrivilege(),
},
},
}),
])
).toEqual({
catalogue: {},
@ -45,15 +60,16 @@ describe('populateUICapabilities', () => {
it('augments the original uiCapabilities with registered feature capabilities', () => {
expect(
uiCapabilitiesForFeatures([
{
new Feature({
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all', ['capability1', 'capability2']),
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(),
},
},
}),
])
).toEqual({
catalogue: {},
@ -67,18 +83,17 @@ describe('populateUICapabilities', () => {
it('combines catalogue entries from multiple features', () => {
expect(
uiCapabilitiesForFeatures([
{
new Feature({
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
catalogue: ['anotherFooEntry', 'anotherBarEntry'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz'),
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(['capability3', 'capability4']),
},
},
}),
])
).toEqual({
catalogue: {
@ -97,17 +112,16 @@ describe('populateUICapabilities', () => {
it(`merges capabilities from all feature privileges`, () => {
expect(
uiCapabilitiesForFeatures([
{
new Feature({
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz', ['capability1', 'capability5']),
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']),
},
},
}),
])
).toEqual({
catalogue: {},
@ -121,44 +135,116 @@ describe('populateUICapabilities', () => {
});
});
it('supports merging multiple features with multiple privileges each', () => {
it(`supports merging features with sub privileges`, () => {
expect(
uiCapabilitiesForFeatures([
{
new Feature({
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz', ['capability1', 'capability5']),
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(['capability3', 'capability4']),
},
},
{
subFeatures: [
{
name: 'sub-feature-1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
createSubFeaturePrivilege('privilege-1', ['capability5']),
createSubFeaturePrivilege('privilege-2', ['capability6']),
],
} as SubFeaturePrivilegeGroupConfig,
{
groupType: 'mutually_exclusive',
privileges: [
createSubFeaturePrivilege('privilege-3', ['capability7']),
createSubFeaturePrivilege('privilege-4', ['capability8']),
],
} as SubFeaturePrivilegeGroupConfig,
],
},
{
name: 'sub-feature-2',
privilegeGroups: [
{
name: 'Group Name',
groupType: 'independent',
privileges: [
createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']),
],
} as SubFeaturePrivilegeGroupConfig,
],
},
],
}),
])
).toEqual({
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
capability5: true,
capability6: true,
capability7: true,
capability8: true,
capability9: true,
capability10: true,
},
});
});
it('supports merging multiple features with multiple privileges each', () => {
expect(
uiCapabilitiesForFeatures([
new Feature({
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(['capability3', 'capability4']),
},
}),
new Feature({
id: 'anotherNewFeature',
name: 'another new feature',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(['capability3', 'capability4']),
},
},
{
}),
new Feature({
id: 'yetAnotherNewFeature',
name: 'yet another new feature',
navLinkId: 'yetAnotherNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all', ['capability1', 'capability2']),
...createFeaturePrivilege('read', []),
...createFeaturePrivilege('somethingInBetween', [
'something1',
'something2',
'something3',
]),
all: createFeaturePrivilege(['capability1', 'capability2']),
read: createFeaturePrivilege(['something1', 'something2', 'something3']),
},
},
subFeatures: [
{
name: 'sub-feature-1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
createSubFeaturePrivilege('privilege-1', ['capability3']),
createSubFeaturePrivilege('privilege-2', ['capability4']),
],
} as SubFeaturePrivilegeGroupConfig,
],
},
],
}),
])
).toEqual({
anotherNewFeature: {
@ -173,11 +259,12 @@ describe('populateUICapabilities', () => {
capability2: true,
capability3: true,
capability4: true,
capability5: true,
},
yetAnotherNewFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
something1: true,
something2: true,
something3: true,

View file

@ -39,7 +39,14 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities {
};
}
Object.values(feature.privileges).forEach(privilege => {
const featurePrivileges = Object.values(feature.privileges ?? {});
if (feature.subFeatures) {
featurePrivileges.push(
...feature.subFeatures.map(sf => sf.privilegeGroups.map(pg => pg.privileges)).flat(2)
);
}
featurePrivileges.forEach(privilege => {
UIFeatureCapabilities[feature.id] = {
...UIFeatureCapabilities[feature.id],
...privilege.ui.reduce(

View file

@ -11,12 +11,15 @@ export const METRICS_FEATURE = {
name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', {
defaultMessage: 'Metrics',
}),
order: 700,
icon: 'metricsApp',
navLinkId: 'metrics',
app: ['infra', 'kibana'],
catalogue: ['infraops'],
privileges: {
all: {
app: ['infra', 'kibana'],
catalogue: ['infraops'],
api: ['infra'],
savedObject: {
all: ['infrastructure-ui-source'],
@ -25,6 +28,8 @@ export const METRICS_FEATURE = {
ui: ['show', 'configureSource', 'save'],
},
read: {
app: ['infra', 'kibana'],
catalogue: ['infraops'],
api: ['infra'],
savedObject: {
all: [],
@ -40,12 +45,15 @@ export const LOGS_FEATURE = {
name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', {
defaultMessage: 'Logs',
}),
order: 800,
icon: 'logsApp',
navLinkId: 'logs',
app: ['infra', 'kibana'],
catalogue: ['infralogging'],
privileges: {
all: {
app: ['infra', 'kibana'],
catalogue: ['infralogging'],
api: ['infra'],
savedObject: {
all: ['infrastructure-ui-source'],
@ -54,6 +62,8 @@ export const LOGS_FEATURE = {
ui: ['show', 'configureSource', 'save'],
},
read: {
app: ['infra', 'kibana'],
catalogue: ['infralogging'],
api: ['infra'],
savedObject: {
all: [],

View file

@ -88,6 +88,7 @@ export class IngestManagerPlugin implements Plugin {
privileges: {
all: {
api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`],
app: [PLUGIN_ID, 'kibana'],
savedObject: {
all: allSavedObjectTypes,
read: [],
@ -96,6 +97,7 @@ export class IngestManagerPlugin implements Plugin {
},
read: {
api: [`${PLUGIN_ID}-read`],
app: [PLUGIN_ID, 'kibana'],
savedObject: {
all: [],
read: allSavedObjectTypes,

View file

@ -70,12 +70,15 @@ export class MlServerPlugin implements Plugin<MlPluginSetup, MlPluginStart, Plug
defaultMessage: 'Machine Learning',
}),
icon: PLUGIN_ICON,
order: 500,
navLinkId: PLUGIN_ID,
app: [PLUGIN_ID, 'kibana'],
catalogue: [PLUGIN_ID],
privileges: {},
privileges: null,
reserved: {
privilege: {
app: [PLUGIN_ID, 'kibana'],
catalogue: [PLUGIN_ID],
savedObject: {
all: [],
read: [],

View file

@ -267,9 +267,11 @@ export class Plugin {
navLinkId: 'monitoring',
app: ['monitoring', 'kibana'],
catalogue: ['monitoring'],
privileges: {},
privileges: null,
reserved: {
privilege: {
app: ['monitoring', 'kibana'],
catalogue: ['monitoring'],
savedObject: {
all: [],
read: [],

View file

@ -48,6 +48,11 @@ export interface SecurityLicenseFeatures {
*/
readonly allowRbac: boolean;
/**
* Indicates whether we allow sub-feature privileges.
*/
readonly allowSubFeaturePrivileges: boolean;
/**
* Describes the layout of the login form if it's displayed.
*/

View file

@ -22,6 +22,7 @@ describe('license features', function() {
allowRoleFieldLevelSecurity: false,
layout: 'error-es-unavailable',
allowRbac: false,
allowSubFeaturePrivileges: false,
});
});
@ -40,6 +41,7 @@ describe('license features', function() {
allowRoleFieldLevelSecurity: false,
layout: 'error-xpack-unavailable',
allowRbac: false,
allowSubFeaturePrivileges: false,
});
});
@ -62,6 +64,7 @@ describe('license features', function() {
"allowRbac": false,
"allowRoleDocumentLevelSecurity": false,
"allowRoleFieldLevelSecurity": false,
"allowSubFeaturePrivileges": false,
"layout": "error-xpack-unavailable",
"showLinks": false,
"showLogin": true,
@ -79,6 +82,7 @@ describe('license features', function() {
"allowRbac": false,
"allowRoleDocumentLevelSecurity": false,
"allowRoleFieldLevelSecurity": false,
"allowSubFeaturePrivileges": false,
"showLinks": false,
"showLogin": false,
"showRoleMappingsManagement": false,
@ -90,7 +94,7 @@ describe('license features', function() {
}
});
it('should show login page and other security elements, allow RBAC but forbid role mappings and document level security if license is basic.', () => {
it('should show login page and other security elements, allow RBAC but forbid role mappings, DLS, and sub-feature privileges if license is basic.', () => {
const mockRawLicense = licensingMock.createLicense({
features: { security: { isEnabled: true, isAvailable: true } },
});
@ -108,6 +112,7 @@ describe('license features', function() {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRbac: true,
allowSubFeaturePrivileges: false,
});
expect(getFeatureSpy).toHaveBeenCalledTimes(1);
expect(getFeatureSpy).toHaveBeenCalledWith('security');
@ -129,10 +134,11 @@ describe('license features', function() {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRbac: false,
allowSubFeaturePrivileges: false,
});
});
it('should allow role mappings, but not DLS/FLS if license = gold', () => {
it('should allow role mappings and sub-feature privileges, but not DLS/FLS if license = gold', () => {
const mockRawLicense = licensingMock.createLicense({
license: { mode: 'gold', type: 'gold' },
features: { security: { isEnabled: true, isAvailable: true } },
@ -149,10 +155,11 @@ describe('license features', function() {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRbac: true,
allowSubFeaturePrivileges: true,
});
});
it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => {
it('should allow to login, allow RBAC, role mappings, sub-feature privileges, and DLS if license >= platinum', () => {
const mockRawLicense = licensingMock.createLicense({
license: { mode: 'platinum', type: 'platinum' },
features: { security: { isEnabled: true, isAvailable: true } },
@ -169,6 +176,7 @@ describe('license features', function() {
allowRoleDocumentLevelSecurity: true,
allowRoleFieldLevelSecurity: true,
allowRbac: true,
allowSubFeaturePrivileges: true,
});
});
});

View file

@ -74,6 +74,7 @@ export class SecurityLicenseService {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRbac: false,
allowSubFeaturePrivileges: false,
layout:
rawLicense !== undefined && !rawLicense?.isAvailable
? 'error-xpack-unavailable'
@ -90,16 +91,18 @@ export class SecurityLicenseService {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRbac: false,
allowSubFeaturePrivileges: false,
};
}
const showRoleMappingsManagement = rawLicense.hasAtLeast('gold');
const isLicenseGoldOrBetter = rawLicense.hasAtLeast('gold');
const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum');
return {
showLogin: true,
allowLogin: true,
showLinks: true,
showRoleMappingsManagement,
showRoleMappingsManagement: isLicenseGoldOrBetter,
allowSubFeaturePrivileges: isLicenseGoldOrBetter,
// Only platinum and trial licenses are compliant with field- and document-level security.
allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter,
allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter,

View file

@ -8,8 +8,8 @@ export { ApiKey, ApiKeyToInvalidate } from './api_key';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
export { BuiltinESPrivileges } from './builtin_es_privileges';
export { FeaturesPrivileges } from './features_privileges';
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
export { FeaturesPrivileges } from './features_privileges';
export {
Role,
RoleIndexPrivilege,
@ -22,7 +22,6 @@ export {
prepareRoleClone,
getExtendedRoleDeprecationNotice,
} from './role';
export { KibanaPrivileges } from './kibana_privileges';
export {
InlineRoleTemplate,
StoredRoleTemplate,

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FeaturesPrivileges } from '../features_privileges';
import { RawKibanaFeaturePrivileges } from '../raw_kibana_privileges';
export class KibanaFeaturePrivileges {
constructor(private readonly featurePrivilegesMap: RawKibanaFeaturePrivileges) {}
public getAllPrivileges(): FeaturesPrivileges {
return Object.entries(this.featurePrivilegesMap).reduce((acc, [featureId, privileges]) => {
return {
...acc,
[featureId]: Object.keys(privileges),
};
}, {});
}
public getPrivileges(featureId: string): string[] {
const featurePrivileges = this.featurePrivilegesMap[featureId];
if (featurePrivileges == null) {
return [];
}
return Object.keys(featurePrivileges);
}
public getActions(featureId: string, privilege: string): string[] {
if (!this.featurePrivilegesMap[featureId]) {
return [];
}
return this.featurePrivilegesMap[featureId][privilege] || [];
}
}

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export class KibanaGlobalPrivileges {
constructor(private readonly globalPrivilegesMap: Record<string, string[]>) {}
public getAllPrivileges(): string[] {
return Object.keys(this.globalPrivilegesMap);
}
public getActions(privilege: string): string[] {
return this.globalPrivilegesMap[privilege] || [];
}
}

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RawKibanaPrivileges } from '../raw_kibana_privileges';
import { KibanaFeaturePrivileges } from './feature_privileges';
import { KibanaGlobalPrivileges } from './global_privileges';
import { KibanaSpacesPrivileges } from './spaces_privileges';
export class KibanaPrivileges {
constructor(private readonly rawKibanaPrivileges: RawKibanaPrivileges) {}
public getGlobalPrivileges() {
return new KibanaGlobalPrivileges(this.rawKibanaPrivileges.global);
}
public getSpacesPrivileges() {
return new KibanaSpacesPrivileges(this.rawKibanaPrivileges.space);
}
public getFeaturePrivileges() {
return new KibanaFeaturePrivileges(this.rawKibanaPrivileges.features);
}
}

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export class KibanaSpacesPrivileges {
constructor(private readonly spacesPrivilegesMap: Record<string, string[]>) {}
public getAllPrivileges(): string[] {
return Object.keys(this.spacesPrivilegesMap);
}
public getActions(privilege: string): string[] {
return this.spacesPrivilegesMap[privilege] || [];
}
}

View file

@ -0,0 +1,208 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature, FeatureConfig } from '../../../../../features/public';
export const createFeature = (
config: Pick<FeatureConfig, 'id' | 'name' | 'subFeatures' | 'reserved' | 'privilegesTooltip'> & {
excludeFromBaseAll?: boolean;
excludeFromBaseRead?: boolean;
}
) => {
const { excludeFromBaseAll, excludeFromBaseRead, ...rest } = config;
return new Feature({
icon: 'discoverApp',
navLinkId: 'kibana:discover',
app: [],
catalogue: [],
privileges: {
all: {
excludeFromBasePrivileges: excludeFromBaseAll,
savedObject: {
all: ['all-type'],
read: ['read-type'],
},
ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`],
},
read: {
excludeFromBasePrivileges: excludeFromBaseRead,
savedObject: {
all: [],
read: ['read-type'],
},
ui: ['read-ui', `read-${config.id}`],
},
},
...rest,
});
};
export const kibanaFeatures = [
createFeature({
id: 'no_sub_features',
name: 'Feature 1: No Sub Features',
}),
createFeature({
id: 'with_sub_features',
name: 'Mutually Exclusive Sub Features',
subFeatures: [
{
name: 'Cool Sub Feature',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'cool_all',
name: 'All',
includeIn: 'all',
savedObject: {
all: ['all-cool-type'],
read: ['read-cool-type'],
},
ui: ['cool_read-ui', 'cool_all-ui'],
},
{
id: 'cool_read',
name: 'Read',
includeIn: 'read',
savedObject: {
all: [],
read: ['read-cool-type'],
},
ui: ['cool_read-ui'],
},
],
},
{
groupType: 'independent',
privileges: [
{
id: 'cool_toggle_1',
name: 'Cool toggle 1',
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
ui: ['cool_toggle_1-ui'],
},
{
id: 'cool_toggle_2',
name: 'Cool toggle 2',
includeIn: 'read',
savedObject: {
all: [],
read: [],
},
ui: ['cool_toggle_2-ui'],
},
{
id: 'cool_excluded_toggle',
name: 'Cool excluded toggle',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: ['cool_excluded_toggle-ui'],
},
],
},
],
},
],
}),
createFeature({
id: 'with_excluded_sub_features',
name: 'Excluded Sub Features',
subFeatures: [
{
name: 'Excluded Sub Feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'cool_toggle_1',
name: 'Cool toggle 1',
includeIn: 'none',
savedObject: {
all: [],
read: [],
},
ui: ['cool_toggle_1-ui'],
},
],
},
],
},
],
}),
createFeature({
id: 'excluded_from_base',
name: 'Excluded from base',
excludeFromBaseAll: true,
excludeFromBaseRead: true,
subFeatures: [
{
name: 'Cool Sub Feature',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'cool_all',
name: 'All',
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
ui: ['cool_read-ui', 'cool_all-ui'],
},
{
id: 'cool_read',
name: 'Read',
includeIn: 'read',
savedObject: {
all: [],
read: [],
},
ui: ['cool_read-ui'],
},
],
},
{
groupType: 'independent',
privileges: [
{
id: 'cool_toggle_1',
name: 'Cool toggle 2',
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
ui: ['cool_toggle_1-ui'],
},
{
id: 'cool_toggle_2',
name: 'Cool toggle 2',
includeIn: 'read',
savedObject: {
all: [],
read: [],
},
ui: ['cool_toggle_2-ui'],
},
],
},
],
},
],
}),
];

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Actions } from '../../../../server/authorization';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { privilegesFactory } from '../../../../server/authorization/privileges';
import { Feature } from '../../../../../features/public';
import { KibanaPrivileges } from '../model';
import { SecurityLicenseFeatures } from '../../..';
export const createRawKibanaPrivileges = (
features: Feature[],
{ allowSubFeaturePrivileges = true } = {}
) => {
const featuresService = {
getFeatures: () => features,
};
const licensingService = {
getFeatures: () => ({ allowSubFeaturePrivileges } as SecurityLicenseFeatures),
};
return privilegesFactory(
new Actions('unit_test_version'),
featuresService,
licensingService
).get();
};
export const createKibanaPrivileges = (
features: Feature[],
{ allowSubFeaturePrivileges = true } = {}
) => {
return new KibanaPrivileges(
createRawKibanaPrivileges(features, { allowSubFeaturePrivileges }),
features
);
};

View file

@ -10,16 +10,10 @@ import { act } from '@testing-library/react';
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
import { Capabilities } from 'src/core/public';
import { Feature } from '../../../../../features/public';
// These modules should be moved into a common directory
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Actions } from '../../../../server/authorization/actions';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { privilegesFactory } from '../../../../server/authorization/privileges';
import { Role } from '../../../../common/model';
import { DocumentationLinksService } from '../documentation_links';
import { EditRolePage } from './edit_role_page';
import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section';
import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section';
import { TransformErrorSection } from './privileges/kibana/transform_error_section';
import { coreMock } from '../../../../../../../src/core/public/mocks';
@ -28,10 +22,12 @@ import { licenseMock } from '../../../../common/licensing/index.mock';
import { userAPIClientMock } from '../../users/index.mock';
import { rolesAPIClientMock, indicesAPIClientMock, privilegesAPIClientMock } from '../index.mock';
import { Space } from '../../../../../spaces/public';
import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section';
import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges';
const buildFeatures = () => {
return [
{
new Feature({
id: 'feature1',
name: 'Feature 1',
icon: 'addDataApp',
@ -45,9 +41,17 @@ const buildFeatures = () => {
read: [],
},
},
read: {
app: ['feature1App'],
ui: ['feature1-ui'],
savedObject: {
all: [],
read: [],
},
},
},
},
{
}),
new Feature({
id: 'feature2',
name: 'Feature 2',
icon: 'addDataApp',
@ -61,17 +65,19 @@ const buildFeatures = () => {
read: ['config'],
},
},
read: {
app: ['feature2App'],
ui: ['feature2-ui'],
savedObject: {
all: [],
read: ['config'],
},
},
},
},
}),
] as Feature[];
};
const buildRawKibanaPrivileges = () => {
return privilegesFactory(new Actions('unit_test_version'), {
getFeatures: () => buildFeatures(),
}).get();
};
const buildBuiltinESPrivileges = () => {
return {
cluster: ['all', 'manage', 'monitor'],
@ -144,7 +150,7 @@ function getProps({
userAPIClient.getUsers.mockResolvedValue([]);
const privilegesAPIClient = privilegesAPIClientMock.create();
privilegesAPIClient.getAll.mockResolvedValue(buildRawKibanaPrivileges());
privilegesAPIClient.getAll.mockResolvedValue(createRawKibanaPrivileges(buildFeatures()));
privilegesAPIClient.getBuiltIn.mockResolvedValue(buildBuiltinESPrivileges());
const license = licenseMock.create();
@ -156,10 +162,6 @@ function getProps({
const { fatalErrors } = coreMock.createSetup();
const { http, docLinks, notifications } = coreMock.createStart();
http.get.mockImplementation(async (path: any) => {
if (path === '/api/features') {
return buildFeatures();
}
if (path === '/api/spaces/space') {
return buildSpaces();
}
@ -175,6 +177,7 @@ function getProps({
privilegesAPIClient,
rolesAPIClient,
userAPIClient,
getFeatures: () => Promise.resolve(buildFeatures()),
notifications,
docLinks: new DocumentationLinksService(docLinks),
fatalErrors,
@ -200,10 +203,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1);
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
@ -226,10 +226,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0);
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
@ -240,10 +237,7 @@ describe('<EditRolePage />', () => {
it('can render when creating a new role', async () => {
const wrapper = mountWithIntl(<EditRolePage {...getProps({ action: 'edit' })} />);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
@ -275,10 +269,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
@ -301,10 +292,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0);
@ -333,10 +321,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(TransformErrorSection)).toHaveLength(1);
expectReadOnlyFormButtons(wrapper);
@ -360,10 +345,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
@ -387,10 +369,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
@ -403,10 +382,7 @@ describe('<EditRolePage />', () => {
<EditRolePage {...getProps({ action: 'edit', spacesEnabled: false })} />
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
expectSaveFormButtons(wrapper);
@ -438,10 +414,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1);
expectSaveFormButtons(wrapper);
@ -464,10 +437,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0);
@ -497,10 +467,7 @@ describe('<EditRolePage />', () => {
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(TransformErrorSection)).toHaveLength(1);
expectReadOnlyFormButtons(wrapper);
@ -522,10 +489,7 @@ describe('<EditRolePage />', () => {
const wrapper = mountWithIntl(<EditRolePage {...{ ...getProps({ action: 'edit' }), http }} />);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
@ -540,13 +504,17 @@ describe('<EditRolePage />', () => {
<EditRolePage {...{ ...getProps({ action: 'edit' }), indexPatterns }} />
);
await act(async () => {
await nextTick();
wrapper.update();
});
await waitForRender(wrapper);
expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0);
expectSaveFormButtons(wrapper);
});
});
async function waitForRender(wrapper: ReactWrapper<any>) {
await act(async () => {
await nextTick();
wrapper.update();
});
}

View file

@ -37,11 +37,11 @@ import {
IHttpFetchError,
NotificationsStart,
} from 'src/core/public';
import { FeaturesPluginStart } from '../../../../../features/public';
import { Feature } from '../../../../../features/common';
import { IndexPatternsContract } from '../../../../../../../src/plugins/data/public';
import { Space } from '../../../../../spaces/public';
import { Feature } from '../../../../../features/public';
import {
KibanaPrivileges,
RawKibanaPrivileges,
Role,
BuiltinESPrivileges,
@ -64,6 +64,7 @@ import { DocumentationLinksService } from '../documentation_links';
import { IndicesAPIClient } from '../indices_api_client';
import { RolesAPIClient } from '../roles_api_client';
import { PrivilegesAPIClient } from '../privileges_api_client';
import { KibanaPrivileges } from '../model';
interface Props {
action: 'edit' | 'clone';
@ -73,6 +74,7 @@ interface Props {
indicesAPIClient: PublicMethodsOf<IndicesAPIClient>;
rolesAPIClient: PublicMethodsOf<RolesAPIClient>;
privilegesAPIClient: PublicMethodsOf<PrivilegesAPIClient>;
getFeatures: FeaturesPluginStart['getFeatures'];
docLinks: DocumentationLinksService;
http: HttpStart;
license: SecurityLicense;
@ -231,11 +233,13 @@ function useSpaces(http: HttpStart, fatalErrors: FatalErrorsSetup, spacesEnabled
return spaces;
}
function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) {
function useFeatures(
getFeatures: FeaturesPluginStart['getFeatures'],
fatalErrors: FatalErrorsSetup
) {
const [features, setFeatures] = useState<Feature[] | null>(null);
useEffect(() => {
http
.get('/api/features')
getFeatures()
.catch((err: IHttpFetchError) => {
// Currently, the `/api/features` endpoint effectively requires the "Global All" kibana privilege (e.g., what
// the `kibana_user` grants), because it returns information about all registered features (#35841). It's
@ -246,14 +250,15 @@ function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) {
// 404 here, and respond in a way that still allows the UI to render itself.
const unauthorizedForFeatures = err.response?.status === 404;
if (unauthorizedForFeatures) {
return [];
return [] as Feature[];
}
fatalErrors.add(err);
throw err;
})
.then(setFeatures);
}, [http, fatalErrors]);
.then(retrievedFeatures => {
setFeatures(retrievedFeatures);
});
}, [fatalErrors, getFeatures]);
return features;
}
@ -268,6 +273,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
rolesAPIClient,
indicesAPIClient,
privilegesAPIClient,
getFeatures,
http,
roleName,
action,
@ -287,7 +293,7 @@ export const EditRolePage: FunctionComponent<Props> = ({
const indexPatternsTitles = useIndexPatternsTitles(indexPatterns, fatalErrors, notifications);
const privileges = usePrivileges(privilegesAPIClient, fatalErrors);
const spaces = useSpaces(http, fatalErrors, spacesEnabled);
const features = useFeatures(http, fatalErrors);
const features = useFeatures(getFeatures, fatalErrors);
const [role, setRole] = useRole(
rolesAPIClient,
fatalErrors,
@ -425,11 +431,11 @@ export const EditRolePage: FunctionComponent<Props> = ({
<div>
<EuiSpacer />
<KibanaPrivilegesRegion
kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges)}
kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)}
spaces={spaces}
spacesEnabled={spacesEnabled}
features={features}
uiCapabilities={uiCapabilities}
canCustomizeSubFeaturePrivileges={license.getFeatures().allowSubFeaturePrivileges}
editable={!isRoleReadOnly}
role={role}
onChange={onRoleChange}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { hasAssignedFeaturePrivileges, isGlobalPrivilegeDefinition } from './privilege_utils';
import { isGlobalPrivilegeDefinition } from './privilege_utils';
describe('isGlobalPrivilegeDefinition', () => {
it('returns true if no spaces are defined', () => {
@ -47,39 +47,3 @@ describe('isGlobalPrivilegeDefinition', () => {
).toEqual(false);
});
});
describe('hasAssignedFeaturePrivileges', () => {
it('returns false if no feature privileges are defined', () => {
expect(
hasAssignedFeaturePrivileges({
spaces: [],
base: [],
feature: {},
})
).toEqual(false);
});
it('returns false if feature privileges are defined but not assigned', () => {
expect(
hasAssignedFeaturePrivileges({
spaces: [],
base: [],
feature: {
foo: [],
},
})
).toEqual(false);
});
it('returns true if feature privileges are defined and assigned', () => {
expect(
hasAssignedFeaturePrivileges({
spaces: [],
base: [],
feature: {
foo: ['all'],
},
})
).toEqual(true);
});
});

View file

@ -16,12 +16,3 @@ export function isGlobalPrivilegeDefinition(privilegeSpec: RoleKibanaPrivilege):
}
return privilegeSpec.spaces.includes('*');
}
/**
* Determines if the passed privilege spec defines feature privileges.
* @param privilegeSpec
*/
export function hasAssignedFeaturePrivileges(privilegeSpec: RoleKibanaPrivilege): boolean {
const featureKeys = Object.keys(privilegeSpec.feature);
return featureKeys.length > 0 && featureKeys.some(key => privilegeSpec.feature[key].length > 0);
}

View file

@ -5,33 +5,17 @@ exports[`<KibanaPrivileges> renders without crashing 1`] = `
iconType="logoKibana"
title="Kibana"
>
<InjectIntl(SpaceAwarePrivilegeSectionUI)
<SpaceAwarePrivilegeSection
canCustomizeSubFeaturePrivileges={true}
editable={true}
features={Array []}
kibanaPrivileges={
KibanaPrivileges {
"rawKibanaPrivileges": Object {
"features": Object {},
"global": Object {},
"reserved": Object {},
"space": Object {},
},
"feature": Map {},
"global": Map {},
"spaces": Map {},
}
}
onChange={[MockFunction]}
privilegeCalculatorFactory={
KibanaPrivilegeCalculatorFactory {
"kibanaPrivileges": KibanaPrivileges {
"rawKibanaPrivileges": Object {
"features": Object {},
"global": Object {},
"reserved": Object {},
"space": Object {},
},
},
"rankedFeaturePrivileges": Object {},
}
}
role={
Object {
"elasticsearch": Object {

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactWrapper } from 'enzyme';
import {
EuiTableRow,
EuiCheckbox,
EuiCheckboxProps,
EuiButtonGroup,
EuiButtonGroupProps,
} from '@elastic/eui';
import { findTestSubject } from 'test_utils/find_test_subject';
import { SubFeatureForm } from '../sub_feature_form';
export function getDisplayedFeaturePrivileges(wrapper: ReactWrapper<any>) {
const allExpanderButtons = findTestSubject(wrapper, 'expandFeaturePrivilegeRow');
allExpanderButtons.forEach(button => button.simulate('click'));
// each expanded row renders its own `EuiTableRow`, so there are 2 rows
// for each feature: one for the primary feature privilege, and one for the sub privilege form
const rows = wrapper.find(EuiTableRow);
return rows.reduce((acc, row) => {
const subFeaturePrivileges = [];
const subFeatureForm = row.find(SubFeatureForm);
if (subFeatureForm.length > 0) {
const { featureId } = subFeatureForm.props();
const independentPrivileges = (subFeatureForm.find(EuiCheckbox) as ReactWrapper<
EuiCheckboxProps
>).reduce((acc2, checkbox) => {
const { id: privilegeId, checked } = checkbox.props();
return checked ? [...acc2, privilegeId] : acc2;
}, [] as string[]);
const mutuallyExclusivePrivileges = (subFeatureForm.find(EuiButtonGroup) as ReactWrapper<
EuiButtonGroupProps
>).reduce((acc2, subPrivButtonGroup) => {
const { idSelected: selectedSubPrivilege } = subPrivButtonGroup.props();
return selectedSubPrivilege && selectedSubPrivilege !== 'none'
? [...acc2, selectedSubPrivilege]
: acc2;
}, [] as string[]);
subFeaturePrivileges.push(...independentPrivileges, ...mutuallyExclusivePrivileges);
return {
...acc,
[featureId]: {
...acc[featureId],
subFeaturePrivileges,
},
};
} else {
const buttonGroup = row.find(EuiButtonGroup);
const { name, idSelected } = buttonGroup.props();
expect(name).toBeDefined();
expect(idSelected).toBeDefined();
const featureId = name!.substr(`featurePrivilege_`.length);
const primaryFeaturePrivilege = idSelected!.substr(`${featureId}_`.length);
return {
...acc,
[featureId]: {
...acc[featureId],
primaryFeaturePrivilege,
},
};
}
}, {} as Record<string, { primaryFeaturePrivilege: string; subFeaturePrivileges: string[] }>);
}

View file

@ -1,39 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FeatureTable can render without spaces 1`] = `
<EuiInMemoryTable
columns={
Array [
Object {
"field": "feature",
"name": "Feature",
"render": [Function],
},
Object {
"field": "privilege",
"name": <span>
<FormattedMessage
defaultMessage="Privilege"
id="xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle"
values={Object {}}
/>
<ChangeAllPrivilegesControl
onChange={[Function]}
privileges={
Array [
"all",
"read",
"none",
]
}
/>
</span>,
"render": [Function],
},
]
}
items={Array []}
responsive={false}
tableLayout="fixed"
/>
`;

View file

@ -7,9 +7,11 @@ import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@e
import { FormattedMessage } from '@kbn/i18n/react';
import _ from 'lodash';
import React, { Component } from 'react';
import { KibanaPrivilege } from '../../../../model';
import { NO_PRIVILEGE_VALUE } from '../constants';
interface Props {
onChange: (privilege: string) => void;
privileges: string[];
privileges: KibanaPrivilege[];
disabled?: boolean;
}
@ -24,7 +26,11 @@ export class ChangeAllPrivilegesControl extends Component<Props, State> {
public render() {
const button = (
<EuiLink onClick={this.onButtonClick} className={'secPrivilegeFeatureChangeAllLink'}>
<EuiLink
onClick={this.onButtonClick}
className={'secPrivilegeFeatureChangeAllLink'}
data-test-subj="changeAllPrivilegesButton"
>
<FormattedMessage
id="xpack.security.management.editRole.changeAllPrivilegesLink"
defaultMessage="(change all)"
@ -35,17 +41,31 @@ export class ChangeAllPrivilegesControl extends Component<Props, State> {
const items = this.props.privileges.map(privilege => {
return (
<EuiContextMenuItem
key={privilege}
key={privilege.id}
data-test-subj={`changeAllPrivileges-${privilege.id}`}
onClick={() => {
this.onSelectPrivilege(privilege);
this.onSelectPrivilege(privilege.id);
}}
disabled={this.props.disabled}
>
{_.capitalize(privilege)}
{_.capitalize(privilege.id)}
</EuiContextMenuItem>
);
});
items.push(
<EuiContextMenuItem
key={NO_PRIVILEGE_VALUE}
data-test-subj={`changeAllPrivileges-${NO_PRIVILEGE_VALUE}`}
onClick={() => {
this.onSelectPrivilege(NO_PRIVILEGE_VALUE);
}}
disabled={this.props.disabled}
>
{_.capitalize(NO_PRIVILEGE_VALUE)}
</EuiContextMenuItem>
);
return (
<EuiPopover
id={'changeAllFeaturePrivilegesPopover'}

View file

@ -3,145 +3,765 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiInMemoryTable } from '@elastic/eui';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator';
import { FeatureTable } from './feature_table';
import { Role } from '../../../../../../../common/model';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { Feature, SubFeatureConfig } from '../../../../../../../../features/public';
import { kibanaFeatures, createFeature } from '../../../../__fixtures__/kibana_features';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import { getDisplayedFeaturePrivileges } from './__fixtures__';
import { findTestSubject } from 'test_utils/find_test_subject';
import { FeatureTableExpandedRow } from './feature_table_expanded_row';
const defaultPrivilegeDefinition = new KibanaPrivileges({
global: {
all: ['api:/*', 'ui:/*'],
read: ['ui:/feature1/foo', 'ui:/feature2/foo'],
},
space: {
all: ['api:/*', 'ui:/*'],
read: ['ui:/feature1/foo', 'ui:/feature2/foo'],
},
features: {
feature1: {
all: ['ui:/feature1/foo', 'ui:/feature1/bar'],
read: ['ui:/feature1/foo'],
},
feature2: {
all: ['ui:/feature2/foo', 'api:/feature2/bar'],
read: ['ui:/feature2/foo'],
},
feature3: {
all: ['ui:/feature3/foo'],
},
feature4: {
all: ['somethingObsecure:/foo'],
},
},
reserved: {},
});
interface BuildRoleOpts {
globalPrivilege?: {
base: string[];
feature: FeaturesPrivileges;
const createRole = (kibana: Role['kibana'] = []): Role => {
return {
name: 'my_role',
elasticsearch: { cluster: [], run_as: [], indices: [] },
kibana,
};
spacesPrivileges?: Array<{
spaces: string[];
base: string[];
feature: FeaturesPrivileges;
}>;
}
const buildRole = (options: BuildRoleOpts = {}) => {
const role: Role = {
name: 'unit test role',
elasticsearch: {
indices: [],
cluster: [],
run_as: [],
},
kibana: [],
};
if (options.globalPrivilege) {
role.kibana.push({
spaces: ['*'],
...options.globalPrivilege,
});
}
if (options.spacesPrivileges) {
role.kibana.push(...options.spacesPrivileges);
}
return role;
};
const buildFeatures = () => {
return [];
interface TestConfig {
features: Feature[];
role: Role;
privilegeIndex: number;
calculateDisplayedPrivileges: boolean;
canCustomizeSubFeaturePrivileges: boolean;
}
const setup = (config: TestConfig) => {
const kibanaPrivileges = createKibanaPrivileges(config.features, {
allowSubFeaturePrivileges: config.canCustomizeSubFeaturePrivileges,
});
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, config.role);
const onChange = jest.fn();
const onChangeAll = jest.fn();
const wrapper = mountWithIntl(
<FeatureTable
role={config.role}
privilegeCalculator={calculator}
kibanaPrivileges={kibanaPrivileges}
onChange={onChange}
onChangeAll={onChangeAll}
canCustomizeSubFeaturePrivileges={config.canCustomizeSubFeaturePrivileges}
privilegeIndex={config.privilegeIndex}
/>
);
const displayedPrivileges = config.calculateDisplayedPrivileges
? getDisplayedFeaturePrivileges(wrapper)
: undefined;
return {
wrapper,
onChange,
onChangeAll,
displayedPrivileges,
};
};
describe('FeatureTable', () => {
it('can render without spaces', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['marketing', 'default'],
base: ['read'],
feature: {
feature1: ['all'],
[true, false].forEach(canCustomizeSubFeaturePrivileges => {
describe(`with sub feature privileges ${
canCustomizeSubFeaturePrivileges ? 'allowed' : 'disallowed'
}`, () => {
it('renders with no granted privileges for an empty role', () => {
const role = createRole([
{
spaces: [],
base: [],
feature: {},
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges,
});
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
no_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'none',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
with_sub_features: {
primaryFeaturePrivilege: 'none',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
});
});
it('renders with all included privileges granted at the space when space base privilege is "all"', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: ['all'],
feature: {},
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 1,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges,
});
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
no_sub_features: {
primaryFeaturePrivilege: 'all',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'all',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
with_sub_features: {
primaryFeaturePrivilege: 'all',
...(canCustomizeSubFeaturePrivileges
? {
subFeaturePrivileges: [
'with_sub_features_cool_toggle_1',
'with_sub_features_cool_toggle_2',
'cool_all',
],
}
: {}),
},
});
});
it('renders the most permissive primary feature privilege when multiple are assigned', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {
with_sub_features: ['read', 'minimal_all', 'all', 'minimal_read'],
},
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 1,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges,
});
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
no_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'none',
...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}),
},
with_sub_features: {
primaryFeaturePrivilege: 'all',
...(canCustomizeSubFeaturePrivileges
? {
subFeaturePrivileges: [
'with_sub_features_cool_toggle_1',
'with_sub_features_cool_toggle_2',
'cool_all',
],
}
: {}),
},
});
});
it('allows all feature privileges to be toggled via "change all"', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {},
},
]);
const { wrapper, onChangeAll } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 0,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges,
});
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click');
expect(onChangeAll).toHaveBeenCalledWith(['read']);
});
it('allows all feature privileges to be unassigned via "change all"', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {
with_sub_features: ['all'],
no_sub_features: ['read'],
with_excluded_sub_features: ['all', 'something else'],
},
},
]);
const { wrapper, onChangeAll } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 0,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges,
});
findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click');
findTestSubject(wrapper, 'changeAllPrivileges-none').simulate('click');
expect(onChangeAll).toHaveBeenCalledWith([]);
});
});
});
it('renders the most permissive sub-feature privilege when multiple are assigned in a mutually-exclusive group', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_all', 'cool_read'],
},
],
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 1,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: true,
});
const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance(
role
);
const wrapper = shallowWithIntl(
<FeatureTable
role={role}
kibanaPrivileges={defaultPrivilegeDefinition}
calculatedPrivileges={calculator.calculateEffectivePrivileges()[0]}
allowedPrivileges={calculator.calculateAllowedPrivileges()[0]}
rankedFeaturePrivileges={{
feature1: ['all', 'read'],
feature2: ['all', 'read'],
feature3: ['all'],
feature4: ['all'],
}}
features={buildFeatures()}
onChange={jest.fn()}
onChangeAll={jest.fn()}
spacesIndex={0}
/>
);
expect(wrapper).toMatchSnapshot();
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
subFeaturePrivileges: [],
},
no_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'none',
subFeaturePrivileges: [],
},
with_sub_features: {
primaryFeaturePrivilege: 'read',
subFeaturePrivileges: ['cool_all'],
},
});
});
it('can render for a specific spaces entry', () => {
const role = buildRole();
const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance(
role
);
const wrapper = mountWithIntl(
<FeatureTable
role={role}
kibanaPrivileges={defaultPrivilegeDefinition}
calculatedPrivileges={calculator.calculateEffectivePrivileges()[0]}
allowedPrivileges={calculator.calculateAllowedPrivileges()[0]}
rankedFeaturePrivileges={{
feature1: ['all', 'read'],
feature2: ['all', 'read'],
feature3: ['all'],
feature4: ['all'],
}}
features={buildFeatures()}
onChange={jest.fn()}
onChangeAll={jest.fn()}
spacesIndex={-1}
/>
);
it('renders a row expander only for features with sub-features', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
]);
const { wrapper } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 1,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges: true,
});
expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1);
kibanaFeatures.forEach(feature => {
const rowExpander = findTestSubject(wrapper, `expandFeaturePrivilegeRow-${feature.id}`);
if (!feature.subFeatures || feature.subFeatures.length === 0) {
expect(rowExpander).toHaveLength(0);
} else {
expect(rowExpander).toHaveLength(1);
}
});
});
it('renders the <FeatureTableExpandedRow> when the row is expanded', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
]);
const { wrapper } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 1,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges: true,
});
expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(0);
findTestSubject(wrapper, 'expandFeaturePrivilegeRow')
.first()
.simulate('click');
expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(1);
});
it('renders with sub-feature privileges granted when primary feature privilege is "all"', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {
unit_test: ['all'],
},
},
]);
const feature = createFeature({
id: 'unit_test',
name: 'Unit Test Feature',
subFeatures: [
{
name: 'Some Sub Feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub-toggle-1',
name: 'Sub Toggle 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'read',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-2'],
},
],
},
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'sub-option-1',
name: 'Sub Option 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-option-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'read',
savedObject: { all: [], read: [] },
ui: ['sub-option-2'],
},
],
},
],
},
] as SubFeatureConfig[],
});
const { displayedPrivileges } = setup({
role,
features: [feature],
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: true,
});
expect(displayedPrivileges).toEqual({
unit_test: {
primaryFeaturePrivilege: 'all',
subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'],
},
});
});
it('renders with some sub-feature privileges granted when primary feature privilege is "read"', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {
unit_test: ['read'],
},
},
]);
const feature = createFeature({
id: 'unit_test',
name: 'Unit Test Feature',
subFeatures: [
{
name: 'Some Sub Feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub-toggle-1',
name: 'Sub Toggle 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'read',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-2'],
},
],
},
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'sub-option-1',
name: 'Sub Option 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-option-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'read',
savedObject: { all: [], read: [] },
ui: ['sub-option-2'],
},
],
},
],
},
] as SubFeatureConfig[],
});
const { displayedPrivileges } = setup({
role,
features: [feature],
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: true,
});
expect(displayedPrivileges).toEqual({
unit_test: {
primaryFeaturePrivilege: 'read',
subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-toggle-2'],
},
});
});
it('renders with excluded sub-feature privileges not granted when primary feature privilege is "all"', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {
unit_test: ['all'],
},
},
]);
const feature = createFeature({
id: 'unit_test',
name: 'Unit Test Feature',
subFeatures: [
{
name: 'Some Sub Feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub-toggle-1',
name: 'Sub Toggle 1',
includeIn: 'none',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'none',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-2'],
},
],
},
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'sub-option-1',
name: 'Sub Option 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-option-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'read',
savedObject: { all: [], read: [] },
ui: ['sub-option-2'],
},
],
},
],
},
] as SubFeatureConfig[],
});
const { displayedPrivileges } = setup({
role,
features: [feature],
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: true,
});
expect(displayedPrivileges).toEqual({
unit_test: {
primaryFeaturePrivilege: 'all',
subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-option-1'],
},
});
});
it('renders with excluded sub-feature privileges granted when explicitly assigned', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {
unit_test: ['all', 'sub-toggle-1'],
},
},
]);
const feature = createFeature({
id: 'unit_test',
name: 'Unit Test Feature',
subFeatures: [
{
name: 'Some Sub Feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub-toggle-1',
name: 'Sub Toggle 1',
includeIn: 'none',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'none',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-2'],
},
],
},
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'sub-option-1',
name: 'Sub Option 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-option-1'],
},
{
id: 'sub-toggle-2',
name: 'Sub Toggle 2',
includeIn: 'read',
savedObject: { all: [], read: [] },
ui: ['sub-option-2'],
},
],
},
],
},
] as SubFeatureConfig[],
});
const { displayedPrivileges } = setup({
role,
features: [feature],
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: true,
});
expect(displayedPrivileges).toEqual({
unit_test: {
primaryFeaturePrivilege: 'all',
subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'],
},
});
});
it('renders with all included sub-feature privileges granted at the space when primary feature privileges are granted', () => {
const role = createRole([
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {
with_sub_features: ['all'],
},
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 1,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: true,
});
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
subFeaturePrivileges: [],
},
no_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'none',
subFeaturePrivileges: [],
},
with_sub_features: {
primaryFeaturePrivilege: 'all',
subFeaturePrivileges: [
'with_sub_features_cool_toggle_1',
'with_sub_features_cool_toggle_2',
'cool_all',
],
},
});
});
it('renders with no privileges granted when minimal feature privileges are assigned, and sub-feature privileges are disallowed', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {
with_sub_features: ['minimal_all'],
},
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: false,
});
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
},
no_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_sub_features: {
primaryFeaturePrivilege: 'none',
},
});
});
it('renders with no privileges granted when sub feature privileges are assigned, and sub-feature privileges are disallowed', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_all'],
},
},
]);
const { displayedPrivileges } = setup({
role,
features: kibanaFeatures,
privilegeIndex: 0,
calculateDisplayedPrivileges: true,
canCustomizeSubFeaturePrivileges: false,
});
expect(displayedPrivileges).toEqual({
excluded_from_base: {
primaryFeaturePrivilege: 'none',
},
no_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_excluded_sub_features: {
primaryFeaturePrivilege: 'none',
},
with_sub_features: {
primaryFeaturePrivilege: 'none',
},
});
});
});

View file

@ -4,103 +4,112 @@
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React, { Component } from 'react';
import {
EuiButtonGroup,
EuiIcon,
EuiIconTip,
EuiInMemoryTable,
EuiText,
IconType,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Feature } from '../../../../../../../../features/public';
import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../../../../../common/model';
import {
AllowedPrivilege,
CalculatedPrivilege,
PrivilegeExplanation,
} from '../kibana_privilege_calculator';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { PrivilegeDisplay } from '../space_aware_privilege_section/privilege_display';
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import React, { Component } from 'react';
import { Role } from '../../../../../../../common/model';
import { ChangeAllPrivilegesControl } from './change_all_privileges';
import { FeatureTableExpandedRow } from './feature_table_expanded_row';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import { FeatureTableCell } from '../feature_table_cell';
import { KibanaPrivileges, SecuredFeature, KibanaPrivilege } from '../../../../model';
interface Props {
role: Role;
features: Feature[];
calculatedPrivileges: CalculatedPrivilege;
allowedPrivileges: AllowedPrivilege;
rankedFeaturePrivileges: FeaturesPrivileges;
privilegeCalculator: PrivilegeFormCalculator;
kibanaPrivileges: KibanaPrivileges;
spacesIndex: number;
privilegeIndex: number;
onChange: (featureId: string, privileges: string[]) => void;
onChangeAll: (privileges: string[]) => void;
canCustomizeSubFeaturePrivileges: boolean;
disabled?: boolean;
}
interface TableFeature extends Feature {
hasAnyPrivilegeAssigned: boolean;
interface State {
expandedFeatures: string[];
}
interface TableRow {
feature: TableFeature;
featureId: string;
feature: SecuredFeature;
inherited: KibanaPrivilege[];
effective: KibanaPrivilege[];
role: Role;
}
export class FeatureTable extends Component<Props, {}> {
export class FeatureTable extends Component<Props, State> {
public static defaultProps = {
spacesIndex: -1,
privilegeIndex: -1,
showLocks: true,
};
public render() {
const { role, features, calculatedPrivileges, rankedFeaturePrivileges } = this.props;
constructor(props: Props) {
super(props);
this.state = {
expandedFeatures: [],
};
}
const items: TableRow[] = features
public render() {
const { role, kibanaPrivileges } = this.props;
const featurePrivileges = kibanaPrivileges.getSecuredFeatures();
const items: TableRow[] = featurePrivileges
.sort((feature1, feature2) => {
if (
Object.keys(feature1.privileges).length === 0 &&
Object.keys(feature2.privileges).length > 0
) {
if (feature1.reserved && !feature2.reserved) {
return 1;
}
if (
Object.keys(feature2.privileges).length === 0 &&
Object.keys(feature1.privileges).length > 0
) {
if (feature2.reserved && !feature1.reserved) {
return -1;
}
return 0;
})
.map(feature => {
const calculatedFeaturePrivileges = calculatedPrivileges.feature[feature.id];
const hasAnyPrivilegeAssigned = Boolean(
calculatedFeaturePrivileges &&
calculatedFeaturePrivileges.actualPrivilege !== NO_PRIVILEGE_VALUE
);
return {
feature: {
...feature,
hasAnyPrivilegeAssigned,
},
featureId: feature.id,
feature,
inherited: [],
effective: [],
role,
};
});
// TODO: This simply grabs the available privileges from the first feature we encounter.
// As of now, features can have 'all' and 'read' as available privileges. Once that assumption breaks,
// this will need updating. This is a simplifying measure to enable the new UI.
const availablePrivileges = Object.values(rankedFeaturePrivileges)[0];
return (
<EuiInMemoryTable
responsive={false}
columns={this.getColumns(availablePrivileges)}
columns={this.getColumns()}
itemId={'featureId'}
itemIdToExpandedRowMap={this.state.expandedFeatures.reduce((acc, featureId) => {
return {
...acc,
[featureId]: (
<FeatureTableExpandedRow
feature={featurePrivileges.find(f => f.id === featureId)!}
privilegeIndex={this.props.privilegeIndex}
onChange={this.props.onChange}
privilegeCalculator={this.props.privilegeCalculator}
selectedFeaturePrivileges={
this.props.role.kibana[this.props.privilegeIndex].feature[featureId] ?? []
}
disabled={this.props.disabled}
/>
),
};
}, {})}
items={items}
/>
);
@ -115,171 +124,157 @@ export class FeatureTable extends Component<Props, {}> {
}
};
private getColumns = (availablePrivileges: string[]) => [
{
field: 'feature',
name: i18n.translate(
'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle',
{ defaultMessage: 'Feature' }
),
render: (feature: TableFeature) => {
let tooltipElement = null;
if (feature.privilegesTooltip) {
const tooltipContent = (
<EuiText>
<p>{feature.privilegesTooltip}</p>
</EuiText>
);
tooltipElement = (
<EuiIconTip
iconProps={{
className: 'eui-alignTop',
}}
type={'iInCircle'}
color={'subdued'}
content={tooltipContent}
private getColumns = () => {
const basePrivileges = this.props.kibanaPrivileges.getBasePrivileges(
this.props.role.kibana[this.props.privilegeIndex]
);
const columns = [];
if (this.props.canCustomizeSubFeaturePrivileges) {
columns.push({
width: '30px',
isExpander: true,
field: 'featureId',
name: '',
render: (featureId: string, record: TableRow) => {
const { feature } = record;
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
if (!hasSubFeaturePrivileges) {
return null;
}
return (
<EuiButtonIcon
onClick={() => this.toggleExpandedFeature(featureId)}
data-test-subj={`expandFeaturePrivilegeRow expandFeaturePrivilegeRow-${featureId}`}
aria-label={this.state.expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'}
iconType={this.state.expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'}
/>
);
}
},
});
}
return (
<span>
<EuiIcon size="m" type={feature.icon as IconType} className="secPrivilegeFeatureIcon" />
{feature.name} {tooltipElement}
</span>
);
columns.push(
{
field: 'feature',
width: '200px',
name: i18n.translate(
'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle',
{
defaultMessage: 'Feature',
}
),
render: (feature: SecuredFeature) => {
return <FeatureTableCell feature={feature} />;
},
},
},
{
field: 'privilege',
name: (
<span>
<FormattedMessage
id="xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle"
defaultMessage="Privilege"
/>
{!this.props.disabled && (
<ChangeAllPrivilegesControl
privileges={[...availablePrivileges, NO_PRIVILEGE_VALUE]}
onChange={this.onChangeAllFeaturePrivileges}
/>
)}
</span>
),
render: (roleEntry: Role, record: TableRow) => {
const { id: featureId, name: featureName, reserved, privileges } = record.feature;
if (reserved && Object.keys(privileges).length === 0) {
return <EuiText size={'s'}>{reserved.description}</EuiText>;
}
const featurePrivileges = this.props.kibanaPrivileges
.getFeaturePrivileges()
.getPrivileges(featureId);
if (featurePrivileges.length === 0) {
return null;
}
const enabledFeaturePrivileges = this.getEnabledFeaturePrivileges(
featurePrivileges,
featureId
);
const privilegeExplanation = this.getPrivilegeExplanation(featureId);
const allowsNone = this.allowsNoneForPrivilegeAssignment(featureId);
const actualPrivilegeValue = privilegeExplanation.actualPrivilege;
const canChangePrivilege =
!this.props.disabled && (allowsNone || enabledFeaturePrivileges.length > 1);
if (!canChangePrivilege) {
const assignedBasePrivilege =
this.props.role.kibana[this.props.spacesIndex].base.length > 0;
const excludedFromBasePrivilegsTooltip = (
{
field: 'privilege',
width: '200px',
name: (
<span>
<FormattedMessage
id="xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip"
defaultMessage='Use "Custom" privileges to grant access. {featureName} isn&apos;t part of the base privileges.'
values={{ featureName }}
id="xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle"
defaultMessage="Privilege"
/>
{!this.props.disabled && (
<ChangeAllPrivilegesControl
privileges={basePrivileges}
onChange={this.onChangeAllFeaturePrivileges}
/>
)}
</span>
),
mobileOptions: {
// Table isn't responsive, so skip rendering this for mobile. <ChangeAllPrivilegesControl /> isn't free...
header: false,
},
render: (roleEntry: Role, record: TableRow) => {
const { feature } = record;
if (feature.reserved) {
return <EuiText size={'s'}>{feature.reserved.description}</EuiText>;
}
const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges();
if (primaryFeaturePrivileges.length === 0) {
return null;
}
const selectedPrivilegeId = this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId(
feature.id,
this.props.privilegeIndex
);
const options = primaryFeaturePrivileges.map(privilege => {
return {
id: `${feature.id}_${privilege.id}`,
label: privilege.name,
isDisabled: this.props.disabled,
};
});
options.push({
id: `${feature.id}_${NO_PRIVILEGE_VALUE}`,
label: 'None',
isDisabled: this.props.disabled,
});
let warningIcon = <EuiIconTip type="empty" content={null} />;
if (
this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges(
feature.id,
this.props.privilegeIndex
)
) {
warningIcon = (
<EuiIconTip
type="iInCircle"
content={
<FormattedMessage
id="xpack.security.management.editRole.featureTable.privilegeCustomizationTooltip"
defaultMessage="Feature has customized sub-feature privileges. Expand this row for more information."
/>
}
/>
);
}
return (
<PrivilegeDisplay
privilege={actualPrivilegeValue}
explanation={privilegeExplanation}
tooltipContent={
assignedBasePrivilege && actualPrivilegeValue === NO_PRIVILEGE_VALUE
? excludedFromBasePrivilegsTooltip
: undefined
}
/>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>{warningIcon}</EuiFlexItem>
<EuiFlexItem>
<EuiButtonGroup
name={`featurePrivilege_${feature.id}`}
data-test-subj={`primaryFeaturePrivilegeControl`}
buttonSize="compressed"
color={'primary'}
isFullWidth={true}
options={options}
idSelected={`${feature.id}_${selectedPrivilegeId ?? NO_PRIVILEGE_VALUE}`}
onChange={this.onChange(feature.id)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const options = availablePrivileges.map(priv => {
return {
id: `${featureId}_${priv}`,
label: _.capitalize(priv),
isDisabled: !enabledFeaturePrivileges.includes(priv),
};
});
options.push({
id: `${featureId}_${NO_PRIVILEGE_VALUE}`,
label: 'None',
isDisabled: !allowsNone,
});
return (
<EuiButtonGroup
name={`featurePrivilege_${featureId}`}
options={options}
idSelected={`${featureId}_${actualPrivilegeValue || NO_PRIVILEGE_VALUE}`}
onChange={this.onChange(featureId)}
/>
);
},
},
];
private getEnabledFeaturePrivileges = (featurePrivileges: string[], featureId: string) => {
const { allowedPrivileges } = this.props;
if (this.isConfiguringGlobalPrivileges()) {
// Global feature privileges are not limited by effective privileges.
return featurePrivileges;
}
const allowedFeaturePrivileges = allowedPrivileges.feature[featureId];
if (allowedFeaturePrivileges == null) {
throw new Error('Unable to get enabled feature privileges for a feature without privileges');
}
return allowedFeaturePrivileges.privileges;
},
}
);
return columns;
};
private getPrivilegeExplanation = (featureId: string): PrivilegeExplanation => {
const { calculatedPrivileges } = this.props;
const calculatedFeaturePrivileges = calculatedPrivileges.feature[featureId];
if (calculatedFeaturePrivileges == null) {
throw new Error('Unable to get privilege explanation for a feature without privileges');
private toggleExpandedFeature = (featureId: string) => {
if (this.state.expandedFeatures.includes(featureId)) {
this.setState({
expandedFeatures: this.state.expandedFeatures.filter(ef => ef !== featureId),
});
} else {
this.setState({
expandedFeatures: [...this.state.expandedFeatures, featureId],
});
}
return calculatedFeaturePrivileges;
};
private allowsNoneForPrivilegeAssignment = (featureId: string): boolean => {
const { allowedPrivileges } = this.props;
const allowedFeaturePrivileges = allowedPrivileges.feature[featureId];
if (allowedFeaturePrivileges == null) {
throw new Error('Unable to determine if none is allowed for a feature without privileges');
}
return allowedFeaturePrivileges.canUnassign;
};
private onChangeAllFeaturePrivileges = (privilege: string) => {
@ -289,7 +284,4 @@ export class FeatureTable extends Component<Props, {}> {
this.props.onChangeAll([privilege]);
}
};
private isConfiguringGlobalPrivileges = () =>
isGlobalPrivilegeDefinition(this.props.role.kibana[this.props.spacesIndex]);
}

View file

@ -0,0 +1,199 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Role } from '../../../../../../../common/model';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { FeatureTableExpandedRow } from './feature_table_expanded_row';
import { findTestSubject } from 'test_utils/find_test_subject';
import { act } from '@testing-library/react';
const createRole = (kibana: Role['kibana'] = []): Role => {
return {
name: 'my_role',
elasticsearch: { cluster: [], run_as: [], indices: [] },
kibana,
};
};
describe('FeatureTableExpandedRow', () => {
it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => {
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()}
/>
);
expect(
wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props()
).toMatchObject({
disabled: false,
checked: true,
});
});
it('indicates sub-feature privileges are not being customized if a primary feature privilege is set', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['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={['read']}
onChange={jest.fn()}
/>
);
expect(
wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props()
).toMatchObject({
disabled: false,
checked: false,
});
});
it('does not allow customizing if a primary privilege is not set', () => {
const role = createRole([
{
base: [],
feature: {},
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={['read']}
onChange={jest.fn()}
/>
);
expect(
wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props()
).toMatchObject({
disabled: true,
checked: false,
});
});
it('switches to the minimal privilege when customizing privileges, including corresponding sub-feature privileges', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['read'],
},
spaces: ['foo'],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const onChange = jest.fn();
const wrapper = mountWithIntl(
<FeatureTableExpandedRow
feature={feature}
privilegeIndex={0}
privilegeCalculator={calculator}
selectedFeaturePrivileges={['read']}
onChange={onChange}
/>
);
act(() => {
findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click');
});
expect(onChange).toHaveBeenCalledWith('with_sub_features', [
'minimal_read',
'cool_read',
'cool_toggle_2',
]);
});
it('switches to the primary privilege when not customizing privileges, removing any other privileges', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'],
},
spaces: ['foo'],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const onChange = jest.fn();
const wrapper = mountWithIntl(
<FeatureTableExpandedRow
feature={feature}
privilegeIndex={0}
privilegeCalculator={calculator}
selectedFeaturePrivileges={['minimal_read', 'cool_read', 'cool_toggle_2']}
onChange={onChange}
/>
);
act(() => {
findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click');
});
expect(onChange).toHaveBeenCalledWith('with_sub_features', ['read']);
});
});

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexItem, EuiFlexGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui';
import { SubFeatureForm } from './sub_feature_form';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import { SecuredFeature } from '../../../../model';
interface Props {
feature: SecuredFeature;
privilegeCalculator: PrivilegeFormCalculator;
privilegeIndex: number;
selectedFeaturePrivileges: string[];
disabled?: boolean;
onChange: (featureId: string, featurePrivileges: string[]) => void;
}
export const FeatureTableExpandedRow = ({
feature,
onChange,
privilegeIndex,
privilegeCalculator,
selectedFeaturePrivileges,
disabled,
}: Props) => {
const [isCustomizing, setIsCustomizing] = useState(() => {
return feature
.getMinimalFeaturePrivileges()
.some(p => selectedFeaturePrivileges.includes(p.id));
});
useEffect(() => {
const hasMinimalFeaturePrivilegeSelected = feature
.getMinimalFeaturePrivileges()
.some(p => selectedFeaturePrivileges.includes(p.id));
if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) {
setIsCustomizing(false);
}
}, [feature, isCustomizing, selectedFeaturePrivileges]);
const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => {
onChange(
feature.id,
privilegeCalculator.updateSelectedFeaturePrivilegesForCustomization(
feature.id,
privilegeIndex,
e.target.checked
)
);
setIsCustomizing(e.target.checked);
};
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<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 ||
!privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex)
}
/>
</EuiFlexItem>
{feature.getSubFeatures().map(subFeature => {
return (
<EuiFlexItem key={subFeature.name}>
<SubFeatureForm
privilegeCalculator={privilegeCalculator}
privilegeIndex={privilegeIndex}
featureId={feature.id}
subFeature={subFeature}
onChange={updatedPrivileges => onChange(feature.id, updatedPrivileges)}
selectedFeaturePrivileges={selectedFeaturePrivileges}
disabled={disabled || !isCustomizing}
/>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,237 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { SecuredSubFeature } from '../../../../model';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import { Role } from '../../../../../../../common/model';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { SubFeatureForm } from './sub_feature_form';
import { EuiCheckbox, EuiButtonGroup } from '@elastic/eui';
import { act } from '@testing-library/react';
// Note: these tests are not concerned with the proper display of privileges,
// as that is verified by the feature_table and privilege_space_form tests.
const createRole = (kibana: Role['kibana'] = []): Role => {
return {
name: 'my_role',
elasticsearch: { cluster: [], run_as: [], indices: [] },
kibana,
};
};
const featureId = 'with_sub_features';
const subFeature = kibanaFeatures.find(kf => kf.id === featureId)!.subFeatures[0];
const securedSubFeature = new SecuredSubFeature(subFeature.toRaw());
describe('SubFeatureForm', () => {
it('renders disabled elements when requested', () => {
const role = createRole([
{
base: [],
feature: {},
spaces: [],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const wrapper = mountWithIntl(
<SubFeatureForm
featureId={featureId}
subFeature={securedSubFeature}
selectedFeaturePrivileges={[]}
privilegeCalculator={calculator}
privilegeIndex={0}
onChange={jest.fn()}
disabled={true}
/>
);
const checkboxes = wrapper.find(EuiCheckbox);
const buttonGroups = wrapper.find(EuiButtonGroup);
expect(checkboxes.everyWhere(checkbox => checkbox.props().disabled === true)).toBe(true);
expect(buttonGroups.everyWhere(checkbox => checkbox.props().isDisabled === true)).toBe(true);
});
it('fires onChange when an independent privilege is selected', () => {
const role = createRole([
{
base: [],
feature: {},
spaces: [],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const onChange = jest.fn();
const wrapper = mountWithIntl(
<SubFeatureForm
featureId={featureId}
subFeature={securedSubFeature}
selectedFeaturePrivileges={[]}
privilegeCalculator={calculator}
privilegeIndex={0}
onChange={onChange}
disabled={false}
/>
);
const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input');
act(() => {
checkbox.simulate('change', { target: { checked: true } });
});
expect(onChange).toHaveBeenCalledWith(['cool_toggle_1']);
});
it('fires onChange when an independent privilege is deselected', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['cool_toggle_1', 'cool_toggle_2'],
},
spaces: [],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const onChange = jest.fn();
const wrapper = mountWithIntl(
<SubFeatureForm
featureId={featureId}
subFeature={securedSubFeature}
selectedFeaturePrivileges={['cool_toggle_1', 'cool_toggle_2']}
privilegeCalculator={calculator}
privilegeIndex={0}
onChange={onChange}
disabled={false}
/>
);
const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input');
act(() => {
checkbox.simulate('change', { target: { checked: false } });
});
expect(onChange).toHaveBeenCalledWith(['cool_toggle_2']);
});
it('fires onChange when a mutually exclusive privilege is selected', () => {
const role = createRole([
{
base: [],
feature: {},
spaces: [],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const onChange = jest.fn();
const wrapper = mountWithIntl(
<SubFeatureForm
featureId={featureId}
subFeature={securedSubFeature}
selectedFeaturePrivileges={[]}
privilegeCalculator={calculator}
privilegeIndex={0}
onChange={onChange}
disabled={false}
/>
);
const button = wrapper.find(EuiButtonGroup);
act(() => {
button.props().onChange('cool_all');
});
expect(onChange).toHaveBeenCalledWith(['cool_all']);
});
it('fires onChange when switching between mutually exclusive options', () => {
const role = createRole([
{
base: [],
feature: {},
spaces: [],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const onChange = jest.fn();
const wrapper = mountWithIntl(
<SubFeatureForm
featureId={featureId}
subFeature={securedSubFeature}
selectedFeaturePrivileges={['cool_all', 'cool_toggle_1']}
privilegeCalculator={calculator}
privilegeIndex={0}
onChange={onChange}
disabled={false}
/>
);
const button = wrapper.find(EuiButtonGroup);
act(() => {
button.props().onChange('cool_read');
});
expect(onChange).toHaveBeenCalledWith(['cool_toggle_1', 'cool_read']);
});
it('fires onChange when a mutually exclusive privilege is deselected', () => {
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['cool_all'],
},
spaces: [],
},
]);
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
const onChange = jest.fn();
const wrapper = mountWithIntl(
<SubFeatureForm
featureId={featureId}
subFeature={securedSubFeature}
selectedFeaturePrivileges={['cool_all']}
privilegeCalculator={calculator}
privilegeIndex={0}
onChange={onChange}
disabled={false}
/>
);
const button = wrapper.find(EuiButtonGroup);
act(() => {
button.props().onChange('none');
});
expect(onChange).toHaveBeenCalledWith([]);
});
});

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiCheckbox, EuiButtonGroup } from '@elastic/eui';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
import {
SecuredSubFeature,
SubFeaturePrivilegeGroup,
SubFeaturePrivilege,
} from '../../../../model';
interface Props {
featureId: string;
subFeature: SecuredSubFeature;
selectedFeaturePrivileges: string[];
privilegeCalculator: PrivilegeFormCalculator;
privilegeIndex: number;
onChange: (selectedPrivileges: string[]) => void;
disabled?: boolean;
}
export const SubFeatureForm = (props: Props) => {
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">{props.subFeature.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem>{props.subFeature.getPrivilegeGroups().map(renderPrivilegeGroup)}</EuiFlexItem>
</EuiFlexGroup>
);
function renderPrivilegeGroup(privilegeGroup: SubFeaturePrivilegeGroup, index: number) {
switch (privilegeGroup.groupType) {
case 'independent':
return renderIndependentPrivilegeGroup(privilegeGroup, index);
case 'mutually_exclusive':
return renderMutuallyExclusivePrivilegeGroup(privilegeGroup, index);
default:
throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`);
}
}
function renderIndependentPrivilegeGroup(
privilegeGroup: SubFeaturePrivilegeGroup,
index: number
) {
return (
<div key={index}>
{privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => {
const isGranted = props.privilegeCalculator.isIndependentSubFeaturePrivilegeGranted(
props.featureId,
privilege.id,
props.privilegeIndex
);
return (
<EuiCheckbox
key={privilege.id}
id={`${props.featureId}_${privilege.id}`}
label={privilege.name}
data-test-subj="independentSubFeaturePrivilegeControl"
onChange={e => {
const { checked } = e.target;
if (checked) {
props.onChange([...props.selectedFeaturePrivileges, privilege.id]);
} else {
props.onChange(props.selectedFeaturePrivileges.filter(sp => sp !== privilege.id));
}
}}
checked={isGranted}
disabled={props.disabled}
compressed={true}
/>
);
})}
</div>
);
}
function renderMutuallyExclusivePrivilegeGroup(
privilegeGroup: SubFeaturePrivilegeGroup,
index: number
) {
const firstSelectedPrivilege = props.privilegeCalculator.getSelectedMutuallyExclusiveSubFeaturePrivilege(
props.featureId,
privilegeGroup,
props.privilegeIndex
);
const options = [
...privilegeGroup.privileges.map((privilege, privilegeIndex) => {
return {
id: privilege.id,
label: privilege.name,
isDisabled: props.disabled,
};
}),
];
options.push({
id: NO_PRIVILEGE_VALUE,
label: 'None',
isDisabled: props.disabled,
});
return (
<EuiButtonGroup
key={index}
buttonSize="compressed"
data-test-subj="mutexSubFeaturePrivilegeControl"
options={options}
idSelected={firstSelectedPrivilege?.id ?? NO_PRIVILEGE_VALUE}
isDisabled={props.disabled}
onChange={selectedPrivilegeId => {
// Deselect all privileges which belong to this mutually-exclusive group
const privilegesWithoutGroupEntries = props.selectedFeaturePrivileges.filter(
sp => !privilegeGroup.privileges.some(privilege => privilege.id === sp)
);
// fire on-change with the newly selected privilege
if (selectedPrivilegeId === NO_PRIVILEGE_VALUE) {
props.onChange(privilegesWithoutGroupEntries);
} else {
props.onChange([...privilegesWithoutGroupEntries, selectedPrivilegeId]);
}
}}
/>
);
}
};

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { createFeature } from '../../../../__fixtures__/kibana_features';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { FeatureTableCell } from '.';
import { SecuredFeature } from '../../../../model';
import { EuiIcon, EuiIconTip } from '@elastic/eui';
describe('FeatureTableCell', () => {
it('renders an icon and feature name', () => {
const feature = createFeature({
id: 'test-feature',
name: 'Test Feature',
});
const wrapper = mountWithIntl(
<FeatureTableCell feature={new SecuredFeature(feature.toRaw())} />
);
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`);
expect(wrapper.find(EuiIcon).props()).toMatchObject({
type: feature.icon,
});
expect(wrapper.find(EuiIconTip)).toHaveLength(0);
});
it('renders an icon and feature name with tooltip when configured', () => {
const feature = createFeature({
id: 'test-feature',
name: 'Test Feature',
privilegesTooltip: 'This is my awesome tooltip content',
});
const wrapper = mountWithIntl(
<FeatureTableCell feature={new SecuredFeature(feature.toRaw())} />
);
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`);
expect(
wrapper
.find(EuiIcon)
.first()
.props()
).toMatchObject({
type: feature.icon,
});
expect(wrapper.find(EuiIconTip).props().content).toMatchInlineSnapshot(`
<EuiText>
<p>
This is my awesome tooltip content
</p>
</EuiText>
`);
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiText, EuiIconTip, EuiIcon, IconType } from '@elastic/eui';
import { SecuredFeature } from '../../../../model';
interface Props {
feature: SecuredFeature;
}
export const FeatureTableCell = ({ feature }: Props) => {
let tooltipElement = null;
if (feature.getPrivilegesTooltip()) {
const tooltipContent = (
<EuiText>
<p>{feature.getPrivilegesTooltip()}</p>
</EuiText>
);
tooltipElement = (
<EuiIconTip
iconProps={{
className: 'eui-alignTop',
}}
type={'iInCircle'}
color={'subdued'}
content={tooltipContent}
/>
);
}
return (
<span>
<EuiIcon size="m" type={feature.icon as IconType} className="secPrivilegeFeatureIcon" />
{feature.name} {tooltipElement}
</span>
);
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { rawKibanaPrivileges } from './raw_kibana_privileges';
export { FeatureTableCell } from './feature_table_cell';

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FeaturesPrivileges, Role } from '../../../../../../../../common/model';
export interface BuildRoleOpts {
spacesPrivileges?: Array<{
spaces: string[];
base: string[];
feature: FeaturesPrivileges;
}>;
}
export const buildRole = (options: BuildRoleOpts = {}) => {
const role: Role = {
name: 'unit test role',
elasticsearch: {
indices: [],
cluster: [],
run_as: [],
},
kibana: [],
};
if (options.spacesPrivileges) {
role.kibana.push(...options.spacesPrivileges);
} else {
role.kibana.push({
spaces: [],
base: [],
feature: {},
});
}
return role;
};

View file

@ -1,60 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const unrestrictedBasePrivileges = {
base: {
privileges: ['all', 'read'],
canUnassign: true,
},
};
export const unrestrictedFeaturePrivileges = {
feature: {
feature1: {
privileges: ['all', 'read'],
canUnassign: true,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: true,
},
feature3: {
privileges: ['all'],
canUnassign: true,
},
feature4: {
privileges: ['all', 'read'],
canUnassign: true,
},
},
};
export const fullyRestrictedBasePrivileges = {
base: {
privileges: ['all'],
canUnassign: false,
},
};
export const fullyRestrictedFeaturePrivileges = {
feature: {
feature1: {
privileges: ['all'],
canUnassign: false,
},
feature2: {
privileges: ['all'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: false,
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
};

View file

@ -1,43 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges } from '../../../../../../../../common/model';
export const defaultPrivilegeDefinition = new KibanaPrivileges({
global: {
all: ['api:/*', 'ui:/*'],
read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/*', 'ui:/feature4/foo'],
},
space: {
all: [
'api:/feature1/*',
'ui:/feature1/*',
'api:/feature2/*',
'ui:/feature2/*',
'ui:/feature3/foo',
'ui:/feature3/foo/*',
'ui:/feature4/foo',
],
read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/bar', 'ui:/feature4/foo'],
},
features: {
feature1: {
all: ['ui:/feature1/foo', 'ui:/feature1/bar'],
read: ['ui:/feature1/foo'],
},
feature2: {
all: ['ui:/feature2/foo', 'api:/feature2/bar'],
read: ['ui:/feature2/foo'],
},
feature3: {
all: ['ui:/feature3/foo', 'ui:/feature3/foo/*'],
},
feature4: {
all: ['somethingObscure:/feature4/foo', 'ui:/feature4/foo'],
read: ['ui:/feature4/foo'],
},
},
reserved: {},
});

View file

@ -1,313 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges, Role } from '../../../../../../../common/model';
import {
buildRole,
defaultPrivilegeDefinition,
fullyRestrictedBasePrivileges,
fullyRestrictedFeaturePrivileges,
unrestrictedBasePrivileges,
unrestrictedFeaturePrivileges,
} from './__fixtures__';
import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator';
import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory';
const buildAllowedPrivilegesCalculator = (
role: Role,
kibanaPrivilege: KibanaPrivileges = defaultPrivilegeDefinition
) => {
return new KibanaAllowedPrivilegesCalculator(kibanaPrivilege, role);
};
const buildEffectivePrivilegesCalculator = (
role: Role,
kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition
) => {
const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges);
return factory.getInstance(role);
};
describe('AllowedPrivileges', () => {
it('allows all privileges when none are currently assigned', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivilegesCalculator(role);
const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role);
const result = allowedPrivilegesCalculator.calculateAllowedPrivileges(
effectivePrivileges.calculateEffectivePrivileges(true)
);
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
]);
});
it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivilegesCalculator(role);
const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role);
const result = allowedPrivilegesCalculator.calculateAllowedPrivileges(
effectivePrivileges.calculateEffectivePrivileges(true)
);
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...fullyRestrictedFeaturePrivileges,
},
{
...fullyRestrictedBasePrivileges,
...fullyRestrictedFeaturePrivileges,
},
]);
});
it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivilegesCalculator(role);
const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role);
const result = allowedPrivilegesCalculator.calculateAllowedPrivileges(
effectivePrivileges.calculateEffectivePrivileges(true)
);
const expectedFeaturePrivileges = {
feature: {
feature1: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: true, // feature 3 has no "read" privilege governed by global "all"
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
};
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...expectedFeaturePrivileges,
},
{
base: {
privileges: ['all', 'read'],
canUnassign: false,
},
...expectedFeaturePrivileges,
},
]);
});
it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {},
},
{
spaces: ['foo'],
base: ['read'],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivilegesCalculator(role);
const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role);
const result = allowedPrivilegesCalculator.calculateAllowedPrivileges(
effectivePrivileges.calculateEffectivePrivileges(true)
);
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
{
base: {
privileges: ['all', 'read'],
canUnassign: true,
},
feature: {
feature1: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: true, // feature 3 has no "read" privilege governed by space "all"
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
},
]);
});
it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['foo'],
base: ['all'],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivilegesCalculator(role);
const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role);
const result = allowedPrivilegesCalculator.calculateAllowedPrivileges(
effectivePrivileges.calculateEffectivePrivileges(true)
);
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
feature: {
feature1: {
privileges: ['all'],
canUnassign: false,
},
feature2: {
privileges: ['all'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: false,
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
},
]);
});
it(`restricts space feature privileges when global feature privileges are set`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['all'],
feature2: ['read'],
feature4: ['all'],
},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivilegesCalculator(role);
const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role);
const result = allowedPrivilegesCalculator.calculateAllowedPrivileges(
effectivePrivileges.calculateEffectivePrivileges(true)
);
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
{
base: {
privileges: ['all', 'read'],
canUnassign: true,
},
feature: {
feature1: {
privileges: ['all'],
canUnassign: false,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: true, // feature 3 has no "read" privilege governed by space "all"
},
feature4: {
privileges: ['all'],
canUnassign: false,
},
},
},
]);
});
});

View file

@ -1,155 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import {
areActionsFullyCovered,
compareActions,
} from '../../../../../../../common/privilege_calculator_utils';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import {
AllowedPrivilege,
CalculatedPrivilege,
PRIVILEGE_SOURCE,
} from './kibana_privilege_calculator_types';
export class KibanaAllowedPrivilegesCalculator {
// reference to the global privilege definition
private globalPrivilege: RoleKibanaPrivilege;
// list of privilege actions that comprise the global base privilege
private readonly assignedGlobalBaseActions: string[];
constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {
this.globalPrivilege = this.locateGlobalPrivilege(role);
this.assignedGlobalBaseActions = this.globalPrivilege.base[0]
? kibanaPrivileges.getGlobalPrivileges().getActions(this.globalPrivilege.base[0])
: [];
}
public calculateAllowedPrivileges(
effectivePrivileges: CalculatedPrivilege[]
): AllowedPrivilege[] {
const { kibana = [] } = this.role;
return kibana.map((privilegeSpec, index) =>
this.calculateAllowedPrivilege(privilegeSpec, effectivePrivileges[index])
);
}
private calculateAllowedPrivilege(
privilegeSpec: RoleKibanaPrivilege,
effectivePrivileges: CalculatedPrivilege
): AllowedPrivilege {
const result: AllowedPrivilege = {
base: {
privileges: [],
canUnassign: true,
},
feature: {},
};
if (isGlobalPrivilegeDefinition(privilegeSpec)) {
// nothing can impede global privileges
result.base.canUnassign = true;
result.base.privileges = this.kibanaPrivileges.getGlobalPrivileges().getAllPrivileges();
} else {
// space base privileges are restricted based on the assigned global privileges
const spacePrivileges = this.kibanaPrivileges.getSpacesPrivileges().getAllPrivileges();
result.base.canUnassign = this.assignedGlobalBaseActions.length === 0;
result.base.privileges = spacePrivileges.filter(privilege => {
// always allowed to assign the calculated effective privilege
if (privilege === effectivePrivileges.base.actualPrivilege) {
return true;
}
const privilegeActions = this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, privilege);
return !areActionsFullyCovered(this.assignedGlobalBaseActions, privilegeActions);
});
}
const allFeaturePrivileges = this.kibanaPrivileges.getFeaturePrivileges().getAllPrivileges();
result.feature = Object.entries(allFeaturePrivileges).reduce(
(acc, [featureId, featurePrivileges]) => {
return {
...acc,
[featureId]: this.getAllowedFeaturePrivileges(
effectivePrivileges,
featureId,
featurePrivileges
),
};
},
{}
);
return result;
}
private getAllowedFeaturePrivileges(
effectivePrivileges: CalculatedPrivilege,
featureId: string,
candidateFeaturePrivileges: string[]
): { privileges: string[]; canUnassign: boolean } {
const effectiveFeaturePrivilegeExplanation = effectivePrivileges.feature[featureId];
if (effectiveFeaturePrivilegeExplanation == null) {
throw new Error('To calculate allowed feature privileges, we need the effective privileges');
}
const effectiveFeatureActions = this.getFeatureActions(
featureId,
effectiveFeaturePrivilegeExplanation.actualPrivilege
);
const privileges = [];
if (effectiveFeaturePrivilegeExplanation.actualPrivilege !== NO_PRIVILEGE_VALUE) {
// Always allowed to assign the calculated effective privilege
privileges.push(effectiveFeaturePrivilegeExplanation.actualPrivilege);
}
privileges.push(
...candidateFeaturePrivileges.filter(privilegeId => {
const candidateActions = this.getFeatureActions(featureId, privilegeId);
return compareActions(effectiveFeatureActions, candidateActions) > 0;
})
);
const result = {
privileges: privileges.sort(),
canUnassign: effectiveFeaturePrivilegeExplanation.actualPrivilege === NO_PRIVILEGE_VALUE,
};
return result;
}
private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) {
switch (source) {
case PRIVILEGE_SOURCE.GLOBAL_BASE:
return this.assignedGlobalBaseActions;
case PRIVILEGE_SOURCE.SPACE_BASE:
return this.kibanaPrivileges.getSpacesPrivileges().getActions(privilegeId);
default:
throw new Error(
`Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}`
);
}
}
private getFeatureActions(featureId: string, privilegeId: string): string[] {
return this.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId);
}
private locateGlobalPrivilege(role: Role) {
const spacePrivileges = role.kibana;
return (
spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || {
spaces: [] as string[],
base: [] as string[],
feature: {},
}
);
}
}

View file

@ -1,321 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { buildRole, defaultPrivilegeDefinition } from './__fixtures__';
import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator';
import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types';
const buildEffectiveBasePrivilegeCalculator = (
role: Role,
kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition
) => {
const globalPrivilegeSpec =
role.kibana.find(k => isGlobalPrivilegeDefinition(k)) ||
({
spaces: ['*'],
base: [],
feature: {},
} as RoleKibanaPrivilege);
const globalActions = globalPrivilegeSpec.base[0]
? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0])
: [];
return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions);
};
describe('getMostPermissiveBasePrivilege', () => {
describe('without ignoring assigned', () => {
it('returns "none" when no privileges are granted', () => {
const role = buildRole();
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[0],
false
);
expect(result).toEqual({
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
} as PrivilegeExplanation);
});
defaultPrivilegeDefinition
.getGlobalPrivileges()
.getAllPrivileges()
.forEach(globalBasePrivilege => {
it(`returns "${globalBasePrivilege}" when assigned directly at the global privilege`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [globalBasePrivilege],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[0],
false
);
expect(result).toEqual({
actualPrivilege: globalBasePrivilege,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
} as PrivilegeExplanation);
});
});
defaultPrivilegeDefinition
.getSpacesPrivileges()
.getAllPrivileges()
.forEach(spaceBasePrivilege => {
it(`returns "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['foo'],
base: [spaceBasePrivilege],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[0],
false
);
expect(result).toEqual({
actualPrivilege: spaceBasePrivilege,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: true,
} as PrivilegeExplanation);
});
});
it('returns the global privilege when no space base is defined', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[1],
false
);
expect(result).toEqual({
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
} as PrivilegeExplanation);
});
it('returns the global privilege when it supercedes the space privilege', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: ['read'],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[1],
false
);
expect(result).toEqual({
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
} as PrivilegeExplanation);
});
});
describe('ignoring assigned', () => {
it('returns "none" when no privileges are granted', () => {
const role = buildRole();
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[0],
true
);
expect(result).toEqual({
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
} as PrivilegeExplanation);
});
defaultPrivilegeDefinition
.getGlobalPrivileges()
.getAllPrivileges()
.forEach(globalBasePrivilege => {
it(`returns "none" when "${globalBasePrivilege}" assigned directly at the global privilege`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [globalBasePrivilege],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[0],
true
);
expect(result).toEqual({
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
} as PrivilegeExplanation);
});
});
defaultPrivilegeDefinition
.getSpacesPrivileges()
.getAllPrivileges()
.forEach(spaceBasePrivilege => {
it(`returns "none" when "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['foo'],
base: [spaceBasePrivilege],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[0],
true
);
expect(result).toEqual({
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: true,
} as PrivilegeExplanation);
});
});
it('returns the global privilege when no space base is defined', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[1],
true
);
expect(result).toEqual({
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
} as PrivilegeExplanation);
});
it('returns the global privilege when it supercedes the space privilege, without indicating override', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: ['read'],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[1],
true
);
expect(result).toEqual({
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
} as PrivilegeExplanation);
});
it('returns the global privilege even though it would ordinarly be overriden by space base privilege', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: ['all'],
feature: {},
},
],
});
const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role);
const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
role.kibana[1],
true
);
expect(result).toEqual({
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
} as PrivilegeExplanation);
});
});
});

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges, RoleKibanaPrivilege } from '../../../../../../../common/model';
import { compareActions } from '../../../../../../../common/privilege_calculator_utils';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types';
export class KibanaBasePrivilegeCalculator {
constructor(
private readonly kibanaPrivileges: KibanaPrivileges,
private readonly globalPrivilege: RoleKibanaPrivilege,
private readonly assignedGlobalBaseActions: string[]
) {}
public getMostPermissiveBasePrivilege(
privilegeSpec: RoleKibanaPrivilege,
ignoreAssigned: boolean
): PrivilegeExplanation {
const assignedPrivilege = privilegeSpec.base[0] || NO_PRIVILEGE_VALUE;
// If this is the global privilege definition, then there is nothing to supercede it.
if (isGlobalPrivilegeDefinition(privilegeSpec)) {
if (assignedPrivilege === NO_PRIVILEGE_VALUE || ignoreAssigned) {
return {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
};
}
return {
actualPrivilege: assignedPrivilege,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
};
}
// Otherwise, check to see if the global privilege supercedes this one.
const baseActions = [
...this.kibanaPrivileges.getSpacesPrivileges().getActions(assignedPrivilege),
];
const globalSupercedes =
this.hasAssignedGlobalBasePrivilege() &&
(compareActions(this.assignedGlobalBaseActions, baseActions) < 0 || ignoreAssigned);
if (globalSupercedes) {
const wasDirectlyAssigned = !ignoreAssigned && baseActions.length > 0;
return {
actualPrivilege: this.globalPrivilege.base[0],
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
...this.buildSupercededFields(
wasDirectlyAssigned,
assignedPrivilege,
PRIVILEGE_SOURCE.SPACE_BASE
),
};
}
if (!ignoreAssigned) {
return {
actualPrivilege: assignedPrivilege,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: true,
};
}
return {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: true,
};
}
private hasAssignedGlobalBasePrivilege() {
return this.assignedGlobalBaseActions.length > 0;
}
private buildSupercededFields(
isSuperceding: boolean,
supersededPrivilege?: string,
supersededPrivilegeSource?: PRIVILEGE_SOURCE
) {
if (!isSuperceding) {
return {};
}
return {
supersededPrivilege,
supersededPrivilegeSource,
};
}
}

View file

@ -1,959 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { buildRole, BuildRoleOpts, defaultPrivilegeDefinition } from './__fixtures__';
import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator';
import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator';
import { PRIVILEGE_SOURCE } from './kibana_privilege_calculator_types';
const buildEffectiveBasePrivilegeCalculator = (
role: Role,
kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition
) => {
const globalPrivilegeSpec =
role.kibana.find(k => isGlobalPrivilegeDefinition(k)) ||
({
spaces: ['*'],
base: [],
feature: {},
} as RoleKibanaPrivilege);
const globalActions = globalPrivilegeSpec.base[0]
? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0])
: [];
return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions);
};
const buildEffectiveFeaturePrivilegeCalculator = (
role: Role,
kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition
) => {
const globalPrivilegeSpec =
role.kibana.find(k => isGlobalPrivilegeDefinition(k)) ||
({
spaces: ['*'],
base: [],
feature: {},
} as RoleKibanaPrivilege);
const globalActions = globalPrivilegeSpec.base[0]
? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0])
: [];
const rankedFeaturePrivileges = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges();
return new KibanaFeaturePrivilegeCalculator(
kibanaPrivileges,
globalPrivilegeSpec,
globalActions,
rankedFeaturePrivileges
);
};
interface TestOpts {
only?: boolean;
role?: BuildRoleOpts;
privilegeIndex?: number;
ignoreAssigned?: boolean;
result: Record<string, any>;
feature?: string;
}
function runTest(
description: string,
{
role: roleOpts = {},
result = {},
privilegeIndex = 0,
ignoreAssigned = false,
only = false,
feature = 'feature1',
}: TestOpts
) {
const fn = only ? it.only : it;
fn(description, () => {
const role = buildRole(roleOpts);
const basePrivilegeCalculator = buildEffectiveBasePrivilegeCalculator(role);
const featurePrivilegeCalculator = buildEffectiveFeaturePrivilegeCalculator(role);
const baseExplanation = basePrivilegeCalculator.getMostPermissiveBasePrivilege(
role.kibana[privilegeIndex],
// If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is
// without ignoring assigned, in order to calculate the correct feature privileges.
false
);
const actualResult = featurePrivilegeCalculator.getMostPermissiveFeaturePrivilege(
role.kibana[privilegeIndex],
baseExplanation,
feature,
ignoreAssigned
);
expect(actualResult).toEqual(result);
});
}
describe('getMostPermissiveFeaturePrivilege', () => {
describe('for global feature privileges, without ignoring assigned', () => {
runTest('returns "none" when no privileges are granted', {
result: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "read" when assigned directly to the feature', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "read" when assigned as the global base privilege', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
],
},
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
});
runTest(
'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['read'],
},
},
],
},
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
},
}
);
runTest(
'returns "all" when assigned as the feature privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {
feature1: ['all'],
},
},
],
},
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
}
);
});
describe('for global feature privileges, ignoring assigned', () => {
runTest('returns "none" when no privileges are granted', {
ignoreAssigned: true,
result: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "none" when "read" is assigned directly to the feature', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
ignoreAssigned: true,
result: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "read" when assigned as the global base privilege', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
],
},
ignoreAssigned: true,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
});
runTest(
'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['read'],
},
},
],
},
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "read" when "all" assigned as the feature privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {
feature1: ['all'],
},
},
],
},
ignoreAssigned: true,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
});
describe('for space feature privileges, without ignoring assigned', () => {
runTest('returns "none" when no privileges are granted', {
role: {
spacesPrivileges: [
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
result: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "read" when assigned directly to the feature', {
role: {
spacesPrivileges: [
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true,
},
});
runTest('returns "read" when assigned as the global base privilege', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
});
runTest(
'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['read'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
},
}
);
runTest(
'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {
feature1: ['all'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the space feature privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['all'],
},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true,
},
}
);
runTest(
'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: ['all'],
feature: {},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
},
}
);
runTest('returns "all" when assigned everywhere, without indicating override', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['all'],
},
},
{
spaces: ['marketing'],
base: ['all'],
feature: {
feature1: ['all'],
},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false,
},
});
runTest('returns "all" when assigned at global feature, overriding space feature', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['all'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
},
});
describe('feature with "all" excluded from base privileges', () => {
runTest('returns "read" when "all" assigned as the global base privilege', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
feature: 'feature4',
privilegeIndex: 1,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
});
runTest(
'returns "read" when "all" assigned as the global base privilege, which does not override assigned space feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature4: ['read'],
},
},
],
},
feature: 'feature4',
privilegeIndex: 1,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false,
},
}
);
runTest(
'returns "all" when assigned as the feature privilege, which is more permissive than the base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature4: ['all'],
},
},
],
},
feature: 'feature4',
privilegeIndex: 1,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true,
},
}
);
});
});
describe('for space feature privileges, ignoring assigned', () => {
runTest('returns "none" when no privileges are granted', {
role: {
spacesPrivileges: [
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
ignoreAssigned: true,
result: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "none" when "read" assigned directly to the feature', {
role: {
spacesPrivileges: [
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
ignoreAssigned: true,
result: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
},
});
runTest('returns "read" when assigned as the global base privilege', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
});
runTest(
'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['read'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {
feature1: ['all'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "read" when "all" assigned as the space feature privilege, which normally overrides assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['all'],
},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['marketing'],
base: ['all'],
feature: {},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
},
}
);
runTest(
'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege',
{
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
}
);
runTest('returns "all" when assigned everywhere, without indicating override', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['all'],
},
},
{
spaces: ['marketing'],
base: ['all'],
feature: {
feature1: ['all'],
},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
},
});
runTest('returns "all" when assigned at global feature, normally overriding space feature', {
role: {
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['all'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
},
},
],
},
privilegeIndex: 1,
ignoreAssigned: true,
result: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: false,
},
});
});
});

View file

@ -1,209 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
FeaturesPrivileges,
KibanaPrivileges,
RoleKibanaPrivilege,
} from '../../../../../../../common/model';
import { areActionsFullyCovered } from '../../../../../../../common/privilege_calculator_utils';
import { NO_PRIVILEGE_VALUE } from '../constants';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import {
PRIVILEGE_SOURCE,
PrivilegeExplanation,
PrivilegeScenario,
} from './kibana_privilege_calculator_types';
export class KibanaFeaturePrivilegeCalculator {
constructor(
private readonly kibanaPrivileges: KibanaPrivileges,
private readonly globalPrivilege: RoleKibanaPrivilege,
private readonly assignedGlobalBaseActions: string[],
private readonly rankedFeaturePrivileges: FeaturesPrivileges
) {}
public getMostPermissiveFeaturePrivilege(
privilegeSpec: RoleKibanaPrivilege,
basePrivilegeExplanation: PrivilegeExplanation,
featureId: string,
ignoreAssigned: boolean
): PrivilegeExplanation {
const scenarios = this.buildFeaturePrivilegeScenarios(
privilegeSpec,
basePrivilegeExplanation,
featureId,
ignoreAssigned
);
const featurePrivileges = this.rankedFeaturePrivileges[featureId] || [];
// inspect feature privileges in ranked order (most permissive -> least permissive)
for (const featurePrivilege of featurePrivileges) {
const actions = this.kibanaPrivileges
.getFeaturePrivileges()
.getActions(featureId, featurePrivilege);
// check if any of the scenarios satisfy the privilege - first one wins.
for (const scenario of scenarios) {
if (areActionsFullyCovered(scenario.actions, actions)) {
return {
actualPrivilege: featurePrivilege,
actualPrivilegeSource: scenario.actualPrivilegeSource,
isDirectlyAssigned: scenario.isDirectlyAssigned,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase:
scenario.directlyAssignedFeaturePrivilegeMorePermissiveThanBase,
...this.buildSupercededFields(
!scenario.isDirectlyAssigned,
scenario.supersededPrivilege,
scenario.supersededPrivilegeSource
),
};
}
}
}
const isGlobal = isGlobalPrivilegeDefinition(privilegeSpec);
return {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: isGlobal
? PRIVILEGE_SOURCE.GLOBAL_FEATURE
: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
};
}
private buildFeaturePrivilegeScenarios(
privilegeSpec: RoleKibanaPrivilege,
basePrivilegeExplanation: PrivilegeExplanation,
featureId: string,
ignoreAssigned: boolean
): PrivilegeScenario[] {
const scenarios: PrivilegeScenario[] = [];
const isGlobalPrivilege = isGlobalPrivilegeDefinition(privilegeSpec);
const assignedGlobalFeaturePrivilege = this.getAssignedFeaturePrivilege(
this.globalPrivilege,
featureId
);
const assignedFeaturePrivilege = this.getAssignedFeaturePrivilege(privilegeSpec, featureId);
const hasAssignedFeaturePrivilege =
!ignoreAssigned && assignedFeaturePrivilege !== NO_PRIVILEGE_VALUE;
scenarios.push({
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
actions: [...this.assignedGlobalBaseActions],
...this.buildSupercededFields(
hasAssignedFeaturePrivilege,
assignedFeaturePrivilege,
isGlobalPrivilege ? PRIVILEGE_SOURCE.GLOBAL_FEATURE : PRIVILEGE_SOURCE.SPACE_FEATURE
),
});
if (!isGlobalPrivilege || !ignoreAssigned) {
scenarios.push({
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
actions: this.getFeatureActions(featureId, assignedGlobalFeaturePrivilege),
isDirectlyAssigned: isGlobalPrivilege && hasAssignedFeaturePrivilege,
...this.buildSupercededFields(
hasAssignedFeaturePrivilege && !isGlobalPrivilege,
assignedFeaturePrivilege,
PRIVILEGE_SOURCE.SPACE_FEATURE
),
});
}
if (isGlobalPrivilege) {
return this.rankScenarios(scenarios);
}
// Otherwise, this is a space feature privilege
const includeSpaceBaseScenario =
basePrivilegeExplanation.actualPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE ||
basePrivilegeExplanation.supersededPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE;
const spaceBasePrivilege =
basePrivilegeExplanation.supersededPrivilege || basePrivilegeExplanation.actualPrivilege;
if (includeSpaceBaseScenario) {
scenarios.push({
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
actions: this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, spaceBasePrivilege),
...this.buildSupercededFields(
hasAssignedFeaturePrivilege,
assignedFeaturePrivilege,
PRIVILEGE_SOURCE.SPACE_FEATURE
),
});
}
if (!ignoreAssigned) {
const actions = this.getFeatureActions(
featureId,
this.getAssignedFeaturePrivilege(privilegeSpec, featureId)
);
const directlyAssignedFeaturePrivilegeMorePermissiveThanBase = !areActionsFullyCovered(
this.assignedGlobalBaseActions,
actions
);
scenarios.push({
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase,
actions,
});
}
return this.rankScenarios(scenarios);
}
private rankScenarios(scenarios: PrivilegeScenario[]): PrivilegeScenario[] {
return scenarios.sort(
(scenario1, scenario2) => scenario1.actualPrivilegeSource - scenario2.actualPrivilegeSource
);
}
private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) {
switch (source) {
case PRIVILEGE_SOURCE.GLOBAL_BASE:
return this.assignedGlobalBaseActions;
case PRIVILEGE_SOURCE.SPACE_BASE:
return this.kibanaPrivileges.getSpacesPrivileges().getActions(privilegeId);
default:
throw new Error(
`Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}`
);
}
}
private getFeatureActions(featureId: string, privilegeId: string) {
return this.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId);
}
private getAssignedFeaturePrivilege(privilegeSpec: RoleKibanaPrivilege, featureId: string) {
const featureEntry = privilegeSpec.feature[featureId] || [];
return featureEntry[0] || NO_PRIVILEGE_VALUE;
}
private buildSupercededFields(
isSuperceding: boolean,
supersededPrivilege?: string,
supersededPrivilegeSource?: PRIVILEGE_SOURCE
) {
if (!isSuperceding) {
return {};
}
return {
supersededPrivilege,
supersededPrivilegeSource,
};
}
}

View file

@ -1,940 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaPrivileges, Role } from '../../../../../../../common/model';
import { NO_PRIVILEGE_VALUE } from '../constants';
import {
buildRole,
defaultPrivilegeDefinition,
fullyRestrictedBasePrivileges,
fullyRestrictedFeaturePrivileges,
unrestrictedBasePrivileges,
unrestrictedFeaturePrivileges,
} from './__fixtures__';
import {
AllowedPrivilege,
PRIVILEGE_SOURCE,
PrivilegeExplanation,
} from './kibana_privilege_calculator_types';
import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory';
const buildEffectivePrivileges = (
role: Role,
kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition
) => {
const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges);
return factory.getInstance(role);
};
interface BuildExpectedFeaturePrivilegesOption {
features: string[];
privilegeExplanation: PrivilegeExplanation;
}
const buildExpectedFeaturePrivileges = (options: BuildExpectedFeaturePrivilegesOption[]) => {
return {
feature: options.reduce((acc1, option) => {
return {
...acc1,
...option.features.reduce((acc2, featureId) => {
return {
...acc2,
[featureId]: option.privilegeExplanation,
};
}, {}),
};
}, {}),
};
};
describe('calculateEffectivePrivileges', () => {
it(`returns an empty array for an empty role`, () => {
const role = buildRole();
role.kibana = [];
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toHaveLength(0);
});
it(`calculates "none" for all privileges when nothing is assigned`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['foo', 'bar'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3', 'feature4'],
privilegeExplanation: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
},
},
]),
},
]);
});
describe(`with global base privilege of "all"`, () => {
it(`calculates global feature privilege of all for features 1-3 and read for feature 4`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
},
]);
});
it(`calculates space base and feature privilege of all for features 1-3 and read for feature 4`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
const calculatedSpacePrivileges = calculatedPrivileges[1];
expect(calculatedSpacePrivileges).toEqual({
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
});
});
describe(`and with feature privileges assigned`, () => {
it('returns the base privileges when they are more permissive', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {
feature1: ['read'],
feature2: ['read'],
feature3: ['read'],
feature4: ['read'],
},
},
{
spaces: ['foo'],
base: [],
feature: {
feature1: ['read'],
feature2: ['read'],
feature3: ['read'],
feature4: ['read'],
},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
},
]),
},
{
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false,
},
},
]),
},
]);
});
});
});
describe(`with global base privilege of "read"`, () => {
it(`it calculates space base and feature privileges when none are provided`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature3'],
privilegeExplanation: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
},
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature3'],
privilegeExplanation: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
},
]);
});
describe('and with feature privileges assigned', () => {
it('returns the feature privileges when they are more permissive', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {
feature1: ['all'],
feature2: ['all'],
feature3: ['all'],
feature4: ['all'],
},
},
{
spaces: ['foo'],
base: [],
feature: {
feature1: ['all'],
feature2: ['all'],
feature3: ['all'],
feature4: ['all'],
},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3', 'feature4'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
},
]),
},
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3', 'feature4'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true,
},
},
]),
},
]);
});
});
});
describe('with both global and space base privileges assigned', () => {
it(`does not override space base of "all" when global base is "read"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: ['all'],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature3'],
privilegeExplanation: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
},
{
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
},
},
]),
},
]);
});
it(`calculates "all" for space base and space features when superceded by global "all"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: ['read'],
feature: {},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
},
{
base: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
isDirectlyAssigned: false,
},
},
]),
},
]);
});
it(`does not override feature privileges when they are more permissive`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: ['read'],
feature: {
feature1: ['all'],
feature2: ['all'],
feature3: ['all'],
feature4: ['all'],
},
},
],
});
const effectivePrivileges = buildEffectivePrivileges(role);
const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges();
expect(calculatedPrivileges).toEqual([
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: true,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
{
features: ['feature3'],
privilegeExplanation: {
actualPrivilege: NO_PRIVILEGE_VALUE,
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE,
isDirectlyAssigned: true,
},
},
{
features: ['feature4'],
privilegeExplanation: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
},
},
]),
},
{
base: {
actualPrivilege: 'read',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
},
...buildExpectedFeaturePrivileges([
{
features: ['feature1', 'feature2', 'feature3', 'feature4'],
privilegeExplanation: {
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE,
isDirectlyAssigned: true,
directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true,
},
},
]),
},
]);
});
});
});
describe('calculateAllowedPrivileges', () => {
it('allows all privileges when none are currently assigned', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const privilegeCalculator = buildEffectivePrivileges(role);
const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges();
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
]);
});
it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['all'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const privilegeCalculator = buildEffectivePrivileges(role);
const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges();
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...fullyRestrictedFeaturePrivileges,
},
{
...fullyRestrictedBasePrivileges,
...fullyRestrictedFeaturePrivileges,
},
]);
});
it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const privilegeCalculator = buildEffectivePrivileges(role);
const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges();
const expectedFeaturePrivileges = {
feature: {
feature1: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: true, // feature 3 has no "read" privilege governed by global "all"
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
};
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...expectedFeaturePrivileges,
},
{
base: {
privileges: ['all', 'read'],
canUnassign: false,
},
...expectedFeaturePrivileges,
},
]);
});
it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {},
},
{
spaces: ['foo'],
base: ['read'],
feature: {},
},
],
});
const privilegeCalculator = buildEffectivePrivileges(role);
const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges();
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
{
base: {
privileges: ['all', 'read'],
canUnassign: true,
},
feature: {
feature1: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: true, // feature 3 has no "read" privilege governed by space "all"
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
},
]);
});
it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['foo'],
base: ['all'],
feature: {},
},
],
});
const privilegeCalculator = buildEffectivePrivileges(role);
const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges();
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
feature: {
feature1: {
privileges: ['all'],
canUnassign: false,
},
feature2: {
privileges: ['all'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: false,
},
feature4: {
privileges: ['all', 'read'],
canUnassign: false,
},
},
},
]);
});
it(`restricts space feature privileges when global feature privileges are set`, () => {
const role = buildRole({
spacesPrivileges: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['all'],
feature2: ['read'],
feature4: ['all'],
},
},
{
spaces: ['foo'],
base: [],
feature: {},
},
],
});
const privilegeCalculator = buildEffectivePrivileges(role);
const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges();
expect(result).toEqual([
{
...unrestrictedBasePrivileges,
...unrestrictedFeaturePrivileges,
},
{
base: {
privileges: ['all', 'read'],
canUnassign: true,
},
feature: {
feature1: {
privileges: ['all'],
canUnassign: false,
},
feature2: {
privileges: ['all', 'read'],
canUnassign: false,
},
feature3: {
privileges: ['all'],
canUnassign: true, // feature 3 has no "read" privilege governed by space "all"
},
feature4: {
privileges: ['all'],
canUnassign: false,
},
},
},
]);
});
});

View file

@ -1,113 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
FeaturesPrivileges,
KibanaPrivileges,
Role,
RoleKibanaPrivilege,
} from '../../../../../../../common/model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator';
import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator';
import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator';
import { AllowedPrivilege, CalculatedPrivilege } from './kibana_privilege_calculator_types';
export class KibanaPrivilegeCalculator {
private allowedPrivilegesCalculator: KibanaAllowedPrivilegesCalculator;
private effectiveBasePrivilegesCalculator: KibanaBasePrivilegeCalculator;
private effectiveFeaturePrivilegesCalculator: KibanaFeaturePrivilegeCalculator;
constructor(
private readonly kibanaPrivileges: KibanaPrivileges,
private readonly role: Role,
public readonly rankedFeaturePrivileges: FeaturesPrivileges
) {
const globalPrivilege = this.locateGlobalPrivilege(role);
const assignedGlobalBaseActions: string[] = globalPrivilege.base[0]
? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilege.base[0])
: [];
this.allowedPrivilegesCalculator = new KibanaAllowedPrivilegesCalculator(
kibanaPrivileges,
role
);
this.effectiveBasePrivilegesCalculator = new KibanaBasePrivilegeCalculator(
kibanaPrivileges,
globalPrivilege,
assignedGlobalBaseActions
);
this.effectiveFeaturePrivilegesCalculator = new KibanaFeaturePrivilegeCalculator(
kibanaPrivileges,
globalPrivilege,
assignedGlobalBaseActions,
rankedFeaturePrivileges
);
}
public calculateEffectivePrivileges(ignoreAssigned: boolean = false): CalculatedPrivilege[] {
const { kibana = [] } = this.role;
return kibana.map(privilegeSpec =>
this.calculateEffectivePrivilege(privilegeSpec, ignoreAssigned)
);
}
public calculateAllowedPrivileges(): AllowedPrivilege[] {
const effectivePrivs = this.calculateEffectivePrivileges(true);
return this.allowedPrivilegesCalculator.calculateAllowedPrivileges(effectivePrivs);
}
private calculateEffectivePrivilege(
privilegeSpec: RoleKibanaPrivilege,
ignoreAssigned: boolean
): CalculatedPrivilege {
const result: CalculatedPrivilege = {
base: this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(
privilegeSpec,
ignoreAssigned
),
feature: {},
reserved: privilegeSpec._reserved,
};
// If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is
// without ignoring assigned, in order to calculate the correct feature privileges.
const effectiveBase = ignoreAssigned
? this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(privilegeSpec, false)
: result.base;
const allFeaturePrivileges = this.kibanaPrivileges.getFeaturePrivileges().getAllPrivileges();
result.feature = Object.keys(allFeaturePrivileges).reduce((acc, featureId) => {
return {
...acc,
[featureId]: this.effectiveFeaturePrivilegesCalculator.getMostPermissiveFeaturePrivilege(
privilegeSpec,
effectiveBase,
featureId,
ignoreAssigned
),
};
}, {});
return result;
}
private locateGlobalPrivilege(role: Role) {
const spacePrivileges = role.kibana;
return (
spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || {
spaces: [] as string[],
base: [] as string[],
feature: {},
}
);
}
}

View file

@ -1,63 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Describes the source of a privilege.
*/
export enum PRIVILEGE_SOURCE {
/** Privilege is assigned directly to the entity */
SPACE_FEATURE = 10,
/** Privilege is derived from space base privilege */
SPACE_BASE = 20,
/** Privilege is derived from global feature privilege */
GLOBAL_FEATURE = 30,
/** Privilege is derived from global base privilege */
GLOBAL_BASE = 40,
}
export interface PrivilegeExplanation {
actualPrivilege: string;
actualPrivilegeSource: PRIVILEGE_SOURCE;
isDirectlyAssigned: boolean;
supersededPrivilege?: string;
supersededPrivilegeSource?: PRIVILEGE_SOURCE;
directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean;
}
export interface CalculatedPrivilege {
base: PrivilegeExplanation;
feature: {
[featureId: string]: PrivilegeExplanation | undefined;
};
reserved: undefined | string[];
}
export interface PrivilegeScenario {
actualPrivilegeSource: PRIVILEGE_SOURCE;
isDirectlyAssigned: boolean;
supersededPrivilege?: string;
supersededPrivilegeSource?: PRIVILEGE_SOURCE;
actions: string[];
directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean;
}
export interface AllowedPrivilege {
base: {
privileges: string[];
canUnassign: boolean;
};
feature: {
[featureId: string]:
| {
privileges: string[];
canUnassign: boolean;
}
| undefined;
};
}

View file

@ -1,81 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
FeaturesPrivileges,
KibanaPrivileges,
Role,
copyRole,
} from '../../../../../../../common/model';
import { compareActions } from '../../../../../../../common/privilege_calculator_utils';
import { KibanaPrivilegeCalculator } from './kibana_privilege_calculator';
export class KibanaPrivilegeCalculatorFactory {
/** All feature privileges, sorted from most permissive => least permissive. */
public readonly rankedFeaturePrivileges: FeaturesPrivileges;
constructor(private readonly kibanaPrivileges: KibanaPrivileges) {
this.rankedFeaturePrivileges = {};
const featurePrivilegeSet = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges();
Object.entries(featurePrivilegeSet).forEach(([featureId, privileges]) => {
this.rankedFeaturePrivileges[featureId] = privileges.sort((privilege1, privilege2) => {
const privilege1Actions = kibanaPrivileges
.getFeaturePrivileges()
.getActions(featureId, privilege1);
const privilege2Actions = kibanaPrivileges
.getFeaturePrivileges()
.getActions(featureId, privilege2);
return compareActions(privilege1Actions, privilege2Actions);
});
});
}
/**
* Creates an KibanaPrivilegeCalculator instance for the specified role.
* @param role
*/
public getInstance(role: Role) {
const roleCopy = copyRole(role);
this.sortPrivileges(roleCopy);
return new KibanaPrivilegeCalculator(
this.kibanaPrivileges,
roleCopy,
this.rankedFeaturePrivileges
);
}
private sortPrivileges(role: Role) {
role.kibana.forEach(privilege => {
privilege.base.sort((privilege1, privilege2) => {
const privilege1Actions = this.kibanaPrivileges
.getSpacesPrivileges()
.getActions(privilege1);
const privilege2Actions = this.kibanaPrivileges
.getSpacesPrivileges()
.getActions(privilege2);
return compareActions(privilege1Actions, privilege2Actions);
});
Object.entries(privilege.feature).forEach(([featureId, featurePrivs]) => {
featurePrivs.sort((privilege1, privilege2) => {
const privilege1Actions = this.kibanaPrivileges
.getFeaturePrivileges()
.getActions(featureId, privilege1);
const privilege2Actions = this.kibanaPrivileges
.getFeaturePrivileges()
.getActions(featureId, privilege2);
return compareActions(privilege1Actions, privilege2Actions);
});
});
});
}
}

View file

@ -6,12 +6,13 @@
import { shallow } from 'enzyme';
import React from 'react';
import { KibanaPrivileges, Role } from '../../../../../../common/model';
import { Role } from '../../../../../../common/model';
import { RoleValidator } from '../../validate_role';
import { KibanaPrivilegesRegion } from './kibana_privileges_region';
import { SimplePrivilegeSection } from './simple_privilege_section';
import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section';
import { TransformErrorSection } from './transform_error_section';
import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section';
import { KibanaPrivileges } from '../../../model';
const buildProps = (customProps = {}) => {
return {
@ -39,12 +40,15 @@ const buildProps = (customProps = {}) => {
},
],
features: [],
kibanaPrivileges: new KibanaPrivileges({
global: {},
space: {},
features: {},
reserved: {},
}),
kibanaPrivileges: new KibanaPrivileges(
{
global: {},
space: {},
features: {},
reserved: {},
},
[]
),
intl: null as any,
uiCapabilities: {
navLinks: {},
@ -57,6 +61,7 @@ const buildProps = (customProps = {}) => {
editable: true,
onChange: jest.fn(),
validator: new RoleValidator(),
canCustomizeSubFeaturePrivileges: true,
...customProps,
};
};

View file

@ -7,21 +7,20 @@
import React, { Component } from 'react';
import { Capabilities } from 'src/core/public';
import { Space } from '../../../../../../../spaces/public';
import { Feature } from '../../../../../../../features/public';
import { KibanaPrivileges, Role } from '../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from './kibana_privilege_calculator';
import { Role } from '../../../../../../common/model';
import { RoleValidator } from '../../validate_role';
import { CollapsiblePanel } from '../../collapsible_panel';
import { SimplePrivilegeSection } from './simple_privilege_section';
import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section';
import { TransformErrorSection } from './transform_error_section';
import { KibanaPrivileges } from '../../../model';
interface Props {
role: Role;
spacesEnabled: boolean;
canCustomizeSubFeaturePrivileges: boolean;
spaces?: Space[];
uiCapabilities: Capabilities;
features: Feature[];
editable: boolean;
kibanaPrivileges: KibanaPrivileges;
onChange: (role: Role) => void;
@ -42,31 +41,28 @@ export class KibanaPrivilegesRegion extends Component<Props, {}> {
kibanaPrivileges,
role,
spacesEnabled,
canCustomizeSubFeaturePrivileges,
spaces = [],
uiCapabilities,
onChange,
editable,
validator,
features,
} = this.props;
if (role._transform_error && role._transform_error.includes('kibana')) {
return <TransformErrorSection />;
}
const privilegeCalculatorFactory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges);
if (spacesEnabled) {
return (
<SpaceAwarePrivilegeSection
kibanaPrivileges={kibanaPrivileges}
role={role}
privilegeCalculatorFactory={privilegeCalculatorFactory}
spaces={spaces}
uiCapabilities={uiCapabilities}
features={features}
onChange={onChange}
editable={editable}
canCustomizeSubFeaturePrivileges={canCustomizeSubFeaturePrivileges}
validator={validator}
/>
);
@ -74,11 +70,10 @@ export class KibanaPrivilegesRegion extends Component<Props, {}> {
return (
<SimplePrivilegeSection
kibanaPrivileges={kibanaPrivileges}
features={features}
role={role}
privilegeCalculatorFactory={privilegeCalculatorFactory}
onChange={onChange}
editable={editable}
canCustomizeSubFeaturePrivileges={canCustomizeSubFeaturePrivileges}
/>
);
}

View file

@ -4,5 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory';
export * from './kibana_privilege_calculator_types';
export { PrivilegeFormCalculator } from './privilege_form_calculator';

View file

@ -0,0 +1,833 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { Role } from '../../../../../../../common/model';
import { PrivilegeFormCalculator } from './privilege_form_calculator';
const createRole = (kibana: Role['kibana'] = []): Role => {
return {
name: 'unit test role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana,
};
};
describe('PrivilegeFormCalculator', () => {
describe('#getBasePrivilege', () => {
it(`returns undefined when no base privilege is assigned`, () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getBasePrivilege(0)).toBeUndefined();
});
it(`ignores unknown base privileges`, () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['unknown'],
feature: {},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getBasePrivilege(0)).toBeUndefined();
});
it(`returns the assigned base privilege`, () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['read'],
feature: {},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getBasePrivilege(0)).toMatchObject({
id: 'read',
});
});
it(`returns the most permissive base privilege when multiple are assigned`, () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['read', 'all'],
feature: {},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getBasePrivilege(0)).toMatchObject({
id: 'all',
});
});
});
describe('#getDisplayedPrimaryFeaturePrivilegeId', () => {
it('returns undefined when no privileges are assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)
).toBeUndefined();
});
it('returns the effective privilege id when a base privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual(
'all'
);
});
it('returns the most permissive assigned primary feature privilege id', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['read', 'all', 'minimal_read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual(
'all'
);
});
it('returns the primary version of the minimal privilege id when assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual(
'read'
);
});
});
describe('#hasCustomizedSubFeaturePrivileges', () => {
it('returns false when no privileges are assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
it('returns false when there are no sub-feature privileges assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
it('returns false when the assigned sub-features are also granted by other assigned privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all', 'cool_all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
it('returns true when the assigned sub-features are not also granted by other assigned privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['read', 'cool_all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true);
});
it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are not assigned ', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true);
});
it('returns false when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are all assigned ', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary does not grant all assigned sub-feature privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [
'minimal_read',
'cool_read',
'cool_toggle_2',
'cool_excluded_toggle',
],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true);
});
it('returns false when a base privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
});
describe('#getEffectivePrimaryFeaturePrivilege', () => {
it('returns undefined when no privileges are assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)
).toBeUndefined();
});
it('returns the most permissive feature privilege granted by the assigned base privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['read'],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({
id: 'read',
});
});
it('returns the most permissive feature privilege granted by the assigned feature privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['read'],
feature: {
with_sub_features: ['read', 'all', 'minimal_all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({
id: 'all',
});
});
it('prefers `read` primary over `mininal_all`', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_all', 'read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({
id: 'read',
});
});
it('returns the minimal primary feature privilege when assigned and not superseded', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({
id: 'minimal_all',
});
});
it('ignores unknown privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['unknown'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)
).toBeUndefined();
});
});
describe('#isIndependentSubFeaturePrivilegeGranted', () => {
it('returns false when no privileges are assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0)
).toEqual(false);
});
it('returns false when an excluded sub-feature privilege is not directly assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.isIndependentSubFeaturePrivilegeGranted(
'with_sub_features',
'cool_excluded_toggle',
0
)
).toEqual(false);
});
it('returns true when an excluded sub-feature privilege is directly assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: ['all', 'cool_excluded_toggle'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.isIndependentSubFeaturePrivilegeGranted(
'with_sub_features',
'cool_excluded_toggle',
0
)
).toEqual(true);
});
it('returns true when a sub-feature privilege is directly assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: ['all', 'cool_toggle_1'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0)
).toEqual(true);
});
it('returns true when a sub-feature privilege is inherited', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0)
).toEqual(true);
});
});
describe('#getSelectedMutuallyExclusiveSubFeaturePrivilege', () => {
it('returns undefined when no privileges are assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!;
const subFeatureGroup = coolSubFeature
.getPrivilegeGroups()
.find(pg => pg.groupType === 'mutually_exclusive')!;
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege(
'with_sub_features',
subFeatureGroup,
0
)
).toBeUndefined();
});
it('returns the inherited privilege when not directly assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
]);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!;
const subFeatureGroup = coolSubFeature
.getPrivilegeGroups()
.find(pg => pg.groupType === 'mutually_exclusive')!;
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege(
'with_sub_features',
subFeatureGroup,
0
)
).toMatchObject({
id: 'cool_all',
});
});
it('returns the the most permissive effective privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all', 'cool_read', 'cool_all'],
},
spaces: ['foo'],
},
]);
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!;
const subFeatureGroup = coolSubFeature
.getPrivilegeGroups()
.find(pg => pg.groupType === 'mutually_exclusive')!;
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege(
'with_sub_features',
subFeatureGroup,
0
)
).toMatchObject({
id: 'cool_all',
});
});
});
describe('#canCustomizeSubFeaturePrivileges', () => {
it('returns false if no privileges are assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
it('returns false if a base privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false);
});
it('returns true if a minimal privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true);
});
it('returns true if a primary feature privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true);
});
});
describe('#updateSelectedFeaturePrivilegesForCustomization', () => {
it('returns the privileges unmodified if no primary feature privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['some-privilege'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true)
).toEqual(['some-privilege']);
expect(
calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false)
).toEqual(['some-privilege']);
});
it('switches to the minimal privilege when customizing, but explicitly grants the sub-feature privileges which were originally inherited', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['read'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true)
).toEqual(['minimal_read', 'cool_read', 'cool_toggle_2']);
});
it('switches to the non-minimal privilege when customizing, removing all other privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(
calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false)
).toEqual(['read']);
});
});
describe('#hasSupersededInheritedPrivileges', () => {
// More exhaustive testing is done at the UI layer: `privilege_space_table.test.tsx`
it('returns false for the global privilege definition', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
{
base: ['all'],
feature: {
with_sub_features: ['read'],
},
spaces: ['*'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasSupersededInheritedPrivileges(1)).toEqual(false);
});
it('returns false when the global privilege is not more permissive', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['foo'],
},
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['*'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false);
});
it('returns true when the global feature privilege is more permissive', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['read'],
},
spaces: ['foo'],
},
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['*'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true);
});
it('returns true when the global base privilege is more permissive', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['read'],
feature: {},
spaces: ['foo'],
},
{
base: ['all'],
feature: {},
spaces: ['*'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true);
});
it('returns false when only the global base privilege is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {},
spaces: ['foo'],
},
{
base: ['all'],
feature: {},
spaces: ['*'],
},
]);
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false);
});
});
});

View file

@ -0,0 +1,303 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Role } from '../../../../../../../common/model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { KibanaPrivileges, SubFeaturePrivilegeGroup } from '../../../../model';
/**
* Calculator responsible for determining the displayed and effective privilege values for the following interfaces:
* - <PrivilegeSpaceForm> and children
* - <PrivilegeSpaceTable> and children
*/
export class PrivilegeFormCalculator {
constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {}
/**
* Returns the assigned base privilege.
* If more than one base privilege is assigned, the most permissive privilege will be returned.
* If no base privileges are assigned, then this will return `undefined`.
*
* @param privilegeIndex the index of the kibana privileges role component
*/
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));
}
/**
* Returns the ID of the *displayed* Primary Feature Privilege for the indicated feature and privilege index.
* If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version.
*
* @example
* The following kibana privilege entry will return `read`:
* ```ts
* const entry = {
* base: [],
* feature: {
* some_feature: ['minimal_read'],
* }
* }
* ```
*
* @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;
}
/**
* Determines if the indicated feature has sub-feature privilege assignments which differ from the "displayed" primary feature privilege.
*
* @param featureId the feature id
* @param privilegeIndex the index of the kibana privileges role component
*/
public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex);
const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([
this.role.kibana[privilegeIndex],
]);
return feature.getSubFeaturePrivileges().some(sfp => {
const isGranted = formPrivileges.grantsPrivilege(sfp);
const isGrantedByDisplayedPrimary = displayedPrimary?.grantsPrivilege(sfp) ?? isGranted;
return isGranted !== isGrantedByDisplayedPrimary;
});
}
/**
* Returns the most permissive effective Primary Feature KibanaPrivilege, including the minimal versions.
*
* @param featureId the feature id
* @param privilegeIndex the index of the kibana privileges role component
*/
public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const basePrivilege = this.getBasePrivilege(privilegeIndex);
const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex);
return feature
.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true })
.find(fp => {
return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp);
});
}
/**
* Determines if the indicated sub-feature privilege is granted.
*
* @param featureId the feature id
* @param privilegeId the sub feature privilege id
* @param privilegeIndex the index of the kibana privileges role component
*/
public isIndependentSubFeaturePrivilegeGranted(
featureId: string,
privilegeId: string,
privilegeIndex: number
) {
const kibanaPrivilege = this.role.kibana[privilegeIndex];
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const subFeaturePrivilege = feature
.getSubFeaturePrivileges()
.find(ap => ap.id === privilegeId)!;
const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([
kibanaPrivilege,
]);
return assignedPrivileges.grantsPrivilege(subFeaturePrivilege);
}
/**
* Returns the most permissive effective privilege within the indicated mutually-exclusive sub feature privilege group.
*
* @param featureId the feature id
* @param subFeatureGroup the mutually-exclusive sub feature group
* @param privilegeIndex the index of the kibana privileges role component
*/
public getSelectedMutuallyExclusiveSubFeaturePrivilege(
featureId: string,
subFeatureGroup: SubFeaturePrivilegeGroup,
privilegeIndex: number
) {
const kibanaPrivilege = this.role.kibana[privilegeIndex];
const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([
kibanaPrivilege,
]);
return subFeatureGroup.privileges.find(p => {
return assignedPrivileges.grantsPrivilege(p);
});
}
/**
* Determines if the indicated feature is capable of having its sub-feature privileges customized.
*
* @param featureId the feature id
* @param privilegeIndex the index of the kibana privileges role component
*/
public canCustomizeSubFeaturePrivileges(featureId: string, privilegeIndex: number) {
const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex);
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
return feature
.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true })
.some(apfp => selectedFeaturePrivileges.includes(apfp.id));
}
/**
* Returns an updated set of feature privileges based on the toggling of the "Customize sub-feature privileges" control.
*
* @param featureId the feature id
* @param privilegeIndex the index of the kibana privileges role component
* @param willBeCustomizing flag indicating if this feature is about to have its sub-feature privileges customized or not
*/
public updateSelectedFeaturePrivilegesForCustomization(
featureId: string,
privilegeIndex: number,
willBeCustomizing: boolean
) {
const primary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex);
const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex);
if (!primary) {
return selectedFeaturePrivileges;
}
const nextPrivileges = [];
if (willBeCustomizing) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const startingPrivileges = feature
.getSubFeaturePrivileges()
.filter(ap => primary.grantsPrivilege(ap))
.map(p => p.id);
nextPrivileges.push(primary.getMinimalPrivilegeId(), ...startingPrivileges);
} else {
nextPrivileges.push(primary.id);
}
return nextPrivileges;
}
/**
* Determines if the indicated privilege entry is less permissive than the configured "global" entry for the role.
* @param privilegeIndex the index of the kibana privileges role component
*/
public hasSupersededInheritedPrivileges(privilegeIndex: number) {
const global = this.locateGlobalPrivilege(this.role);
const entry = this.role.kibana[privilegeIndex];
if (isGlobalPrivilegeDefinition(entry) || !global) {
return false;
}
const globalPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([
global,
]);
const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]);
const hasAssignedBasePrivileges = this.kibanaPrivileges
.getBasePrivileges(entry)
.some(base => entry.base.includes(base.id));
const featuresWithDirectlyAssignedPrivileges = this.kibanaPrivileges
.getSecuredFeatures()
.filter(feature =>
feature
.getAllPrivileges()
.some(privilege => entry.feature[feature.id]?.includes(privilege.id))
);
const hasSupersededBasePrivileges =
hasAssignedBasePrivileges &&
this.kibanaPrivileges
.getBasePrivileges(entry)
.some(
privilege =>
globalPrivileges.grantsPrivilege(privilege) &&
!formPrivileges.grantsPrivilege(privilege)
);
const hasSupersededFeaturePrivileges = featuresWithDirectlyAssignedPrivileges.some(feature =>
feature
.getAllPrivileges()
.some(fp => globalPrivileges.grantsPrivilege(fp) && !formPrivileges.grantsPrivilege(fp))
);
return hasSupersededBasePrivileges || hasSupersededFeaturePrivileges;
}
/**
* Returns the *displayed* Primary Feature Privilege for the indicated feature and privilege index.
* If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version.
*
* @example
* The following kibana privilege entry will return `read`:
* ```ts
* const entry = {
* base: [],
* feature: {
* some_feature: ['minimal_read'],
* }
* }
* ```
*
* @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) {
const feature = this.kibanaPrivileges.getSecuredFeature(featureId);
const basePrivilege = this.getBasePrivilege(privilegeIndex);
const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex);
return feature.getPrimaryFeaturePrivileges().find(fp => {
const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId();
const correspendingMinimalPrivilege = feature
.getMinimalFeaturePrivileges()
.find(mp => mp.id === correspondingMinimalPrivilegeId)!;
// There are two cases where the minimal privileges aren't available:
// 1. The feature has no registered sub-features
// 2. 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 =
feature.subFeatures.length > 0 && fp.grantsPrivilege(correspendingMinimalPrivilege);
return (
selectedFeaturePrivileges.includes(fp.id) ||
(hasMinimalPrivileges &&
selectedFeaturePrivileges.includes(correspondingMinimalPrivilegeId)) ||
basePrivilege?.grantsPrivilege(fp)
);
});
}
private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) {
return this.role.kibana[privilegeIndex].feature[featureId] ?? [];
}
private locateGlobalPrivilege(role: Role) {
return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry));
}
}

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactWrapper } from 'enzyme';
import { EuiTableRow } from '@elastic/eui';
import { findTestSubject } from 'test_utils/find_test_subject';
import { Role, RoleKibanaPrivilege } from '../../../../../../../../common/model';
import { PrivilegeSummaryExpandedRow } from '../privilege_summary_expanded_row';
import { FeatureTableCell } from '../../feature_table_cell';
interface DisplayedFeaturePrivileges {
[featureId: string]: {
[spaceGroup: string]: {
primaryFeaturePrivilege: string;
subFeaturesPrivileges: {
[subFeatureName: string]: string[];
};
hasCustomizedSubFeaturePrivileges: boolean;
};
};
}
const getSpaceKey = (entry: RoleKibanaPrivilege) => entry.spaces.join(', ');
export function getDisplayedFeaturePrivileges(
wrapper: ReactWrapper<any>,
role: Role
): DisplayedFeaturePrivileges {
const allExpanderButtons = findTestSubject(wrapper, 'expandPrivilegeSummaryRow');
allExpanderButtons.forEach(button => button.simulate('click'));
// each expanded row renders its own `EuiTableRow`, so there are 2 rows
// for each feature: one for the primary feature privilege, and one for the sub privilege form
const rows = wrapper.find(EuiTableRow);
return rows.reduce((acc, row) => {
const expandedRow = row.find(PrivilegeSummaryExpandedRow);
if (expandedRow.length > 0) {
return {
...acc,
...getDisplayedSubFeaturePrivileges(acc, expandedRow, role),
};
} else {
const feature = row.find(FeatureTableCell).props().feature;
const primaryFeaturePrivileges = findTestSubject(row, 'privilegeColumn');
expect(primaryFeaturePrivileges).toHaveLength(role.kibana.length);
acc[feature.id] = acc[feature.id] ?? {};
primaryFeaturePrivileges.forEach((primary, index) => {
const key = getSpaceKey(role.kibana[index]);
acc[feature.id][key] = {
...acc[feature.id][key],
primaryFeaturePrivilege: primary.text().trim(),
hasCustomizedSubFeaturePrivileges:
findTestSubject(primary, 'additionalPrivilegesGranted').length > 0,
};
});
return acc;
}
}, {} as DisplayedFeaturePrivileges);
}
function getDisplayedSubFeaturePrivileges(
displayedFeatures: DisplayedFeaturePrivileges,
expandedRow: ReactWrapper<any>,
role: Role
) {
const { feature } = expandedRow.props();
const subFeatureEntries = findTestSubject(expandedRow as ReactWrapper<any>, 'subFeatureEntry');
displayedFeatures[feature.id] = displayedFeatures[feature.id] ?? {};
subFeatureEntries.forEach(subFeatureEntry => {
const subFeatureName = findTestSubject(subFeatureEntry, 'subFeatureName').text();
const entryElements = findTestSubject(subFeatureEntry as ReactWrapper<any>, 'entry', '|=');
expect(entryElements).toHaveLength(role.kibana.length);
role.kibana.forEach((entry, index) => {
const key = getSpaceKey(entry);
const element = findTestSubject(expandedRow as ReactWrapper<any>, `entry-${index}`);
const independentPrivileges = element
.find('EuiFlexGroup[data-test-subj="independentPrivilege"]')
.reduce((acc2, flexGroup) => {
const privilegeName = findTestSubject(flexGroup, 'privilegeName').text();
const isGranted = flexGroup.exists('EuiIconTip[type="check"]');
if (isGranted) {
return [...acc2, privilegeName];
}
return acc2;
}, [] as string[]);
const mutuallyExclusivePrivileges = element
.find('EuiFlexGroup[data-test-subj="mutexPrivilege"]')
.reduce((acc2, flexGroup) => {
const privilegeName = findTestSubject(flexGroup, 'privilegeName').text();
const isGranted = flexGroup.exists('EuiIconTip[type="check"]');
if (isGranted) {
return [...acc2, privilegeName];
}
return acc2;
}, [] as string[]);
displayedFeatures[feature.id][key] = {
...displayedFeatures[feature.id][key],
subFeaturesPrivileges: {
...displayedFeatures[feature.id][key].subFeaturesPrivileges,
[subFeatureName]: [...independentPrivileges, ...mutuallyExclusivePrivileges],
},
};
});
});
return displayedFeatures;
}

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { KibanaPrivileges } from './kibana_privileges';
export { PrivilegeSummary } from './privilege_summary';

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { RoleKibanaPrivilege } from '../../../../../../../common/model';
import { PrivilegeSummary } from '.';
import { findTestSubject } from 'test_utils/find_test_subject';
import { PrivilegeSummaryTable } from './privilege_summary_table';
const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({
name: 'some-role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: roleKibanaPrivileges,
});
const spaces = [
{
id: 'default',
name: 'Default Space',
disabledFeatures: [],
},
];
describe('PrivilegeSummary', () => {
it('initially renders a button', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {},
spaces: ['default'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummary
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={true}
/>
);
expect(findTestSubject(wrapper, 'viewPrivilegeSummaryButton')).toHaveLength(1);
expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(0);
});
it('clicking the button renders the privilege summary table', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {},
spaces: ['default'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummary
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={true}
/>
);
findTestSubject(wrapper, 'viewPrivilegeSummaryButton').simulate('click');
expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(1);
});
});

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiModal,
EuiButtonEmpty,
EuiOverlayMask,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiModalFooter,
EuiButton,
} from '@elastic/eui';
import { Space } from '../../../../../../../../spaces/common/model/space';
import { Role } from '../../../../../../../common/model';
import { PrivilegeSummaryTable } from './privilege_summary_table';
import { KibanaPrivileges } from '../../../../model';
interface Props {
role: Role;
spaces: Space[];
kibanaPrivileges: KibanaPrivileges;
canCustomizeSubFeaturePrivileges: boolean;
}
export const PrivilegeSummary = (props: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Fragment>
<EuiButtonEmpty onClick={() => setIsOpen(true)} data-test-subj="viewPrivilegeSummaryButton">
<FormattedMessage
id="xpack.security.management.editRole.privilegeSummary.viewSummaryButtonText"
defaultMessage="View privilege summary"
/>
</EuiButtonEmpty>
{isOpen && (
<EuiOverlayMask>
<EuiModal onClose={() => setIsOpen(false)} maxWidth={false}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.security.management.editRole.privilegeSummary.modalHeaderTitle"
defaultMessage="Privilege summary"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<PrivilegeSummaryTable
role={props.role}
spaces={props.spaces}
kibanaPrivileges={props.kibanaPrivileges}
canCustomizeSubFeaturePrivileges={props.canCustomizeSubFeaturePrivileges}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={() => setIsOpen(false)}>
<FormattedMessage
id="xpack.security.management.editRole.privilegeSummary.closeSummaryButtonText"
defaultMessage="Close"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
)}
</Fragment>
);
};

View file

@ -0,0 +1,338 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Role } from '../../../../../../../common/model';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { PrivilegeSummaryCalculator } from './privilege_summary_calculator';
const createRole = (kibana: Role['kibana'] = []): Role => {
return {
name: 'unit test role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana,
};
};
describe('PrivilegeSummaryCalculator', () => {
describe('#getEffectiveFeaturePrivileges', () => {
it('returns an empty privilege set when nothing is assigned', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: [],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
});
});
it('calculates effective privileges when inherited from the global privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {},
spaces: ['foo'],
},
{
base: ['all'],
feature: {},
spaces: ['*'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'],
},
});
});
it('calculates effective privileges when there are non-superseded sub-feature privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['cool_excluded_toggle'],
},
spaces: ['foo'],
},
{
base: ['all'],
feature: {
with_sub_features: [],
},
spaces: ['*'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: true,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [
'cool_all',
'cool_read',
'cool_toggle_1',
'cool_toggle_2',
'cool_excluded_toggle',
],
},
});
});
it('calculates privileges for all features for a space entry', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['read'],
feature: {
excluded_from_base: ['all'],
no_sub_features: ['read'],
with_excluded_sub_features: ['all'],
with_sub_features: ['minimal_read', 'cool_excluded_toggle'],
},
spaces: ['foo'],
},
{
base: ['all'],
feature: {},
spaces: ['*'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: true,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [
'cool_all',
'cool_read',
'cool_toggle_1',
'cool_toggle_2',
'cool_excluded_toggle',
],
},
});
});
it('calculates privileges for all features for a global entry', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: ['all'],
feature: {},
spaces: ['*'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'],
},
});
});
it('calculates privileges for a single feature at a space entry', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_excluded_sub_features: ['all'],
},
spaces: ['foo'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
});
});
it('calculates privileges for a single feature at the global entry', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
const role = createRole([
{
base: [],
feature: {
with_excluded_sub_features: ['all'],
},
spaces: ['*'],
},
]);
const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role);
expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({
excluded_from_base: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
no_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
with_excluded_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: expect.objectContaining({
id: 'all',
}),
subFeature: [],
},
with_sub_features: {
hasCustomizedSubFeaturePrivileges: false,
primary: undefined,
subFeature: [],
},
});
});
});
});

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model';
import { PrivilegeCollection } from '../../../../model/privilege_collection';
export interface EffectiveFeaturePrivileges {
[featureId: string]: {
primary?: PrimaryFeaturePrivilege;
subFeature: string[];
hasCustomizedSubFeaturePrivileges: boolean;
};
}
export class PrivilegeSummaryCalculator {
constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {}
public getEffectiveFeaturePrivileges(entry: RoleKibanaPrivilege): EffectiveFeaturePrivileges {
const assignedPrivileges = this.collectAssignedPrivileges(entry);
const features = this.kibanaPrivileges.getSecuredFeatures();
return features.reduce((acc, feature) => {
const displayedPrimaryFeaturePrivilege = this.getDisplayedPrimaryFeaturePrivilege(
assignedPrivileges,
feature
);
const effectiveSubPrivileges = feature
.getSubFeaturePrivileges()
.filter(ap => assignedPrivileges.grantsPrivilege(ap));
const hasCustomizedSubFeaturePrivileges = this.hasCustomizedSubFeaturePrivileges(
feature,
displayedPrimaryFeaturePrivilege,
entry
);
return {
...acc,
[feature.id]: {
primary: displayedPrimaryFeaturePrivilege,
hasCustomizedSubFeaturePrivileges,
subFeature: effectiveSubPrivileges.map(p => p.id),
},
};
}, {} as EffectiveFeaturePrivileges);
}
private hasCustomizedSubFeaturePrivileges(
feature: SecuredFeature,
displayedPrimaryFeaturePrivilege: PrimaryFeaturePrivilege | undefined,
entry: RoleKibanaPrivilege
) {
const formPrivileges = this.collectAssignedPrivileges(entry);
return feature.getSubFeaturePrivileges().some(sfp => {
const isGranted = formPrivileges.grantsPrivilege(sfp);
const isGrantedByDisplayedPrimary =
displayedPrimaryFeaturePrivilege?.grantsPrivilege(sfp) ?? isGranted;
// if displayed primary is derived from base, then excluded sub-feature-privs should not count.
return isGranted !== isGrantedByDisplayedPrimary;
});
}
private getDisplayedPrimaryFeaturePrivilege(
assignedPrivileges: PrivilegeCollection,
feature: SecuredFeature
) {
const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges();
const minimalPrimaryFeaturePrivileges = feature.getMinimalFeaturePrivileges();
const hasMinimalPrivileges = feature.subFeatures.length > 0;
const effectivePrivilege = primaryFeaturePrivileges.find(pfp => {
const isPrimaryGranted = assignedPrivileges.grantsPrivilege(pfp);
if (!isPrimaryGranted && hasMinimalPrivileges) {
const correspondingMinimal = minimalPrimaryFeaturePrivileges.find(
mpfp => mpfp.id === pfp.getMinimalPrivilegeId()
)!;
return assignedPrivileges.grantsPrivilege(correspondingMinimal);
}
return isPrimaryGranted;
});
return effectivePrivilege;
}
private collectAssignedPrivileges(entry: RoleKibanaPrivilege) {
if (isGlobalPrivilegeDefinition(entry)) {
return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]);
}
const globalPrivilege = this.locateGlobalPrivilege(this.role);
return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(
globalPrivilege ? [globalPrivilege, entry] : [entry]
);
}
private locateGlobalPrivilege(role: Role) {
return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry));
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIconTip } from '@elastic/eui';
import { SecuredFeature, SubFeaturePrivilegeGroup, SubFeaturePrivilege } from '../../../../model';
import { EffectiveFeaturePrivileges } from './privilege_summary_calculator';
interface Props {
feature: SecuredFeature;
effectiveFeaturePrivileges: Array<EffectiveFeaturePrivileges['featureId']>;
}
export const PrivilegeSummaryExpandedRow = (props: Props) => {
return (
<EuiFlexGroup direction="column">
{props.feature.getSubFeatures().map(subFeature => {
return (
<EuiFlexItem key={subFeature.name} data-test-subj="subFeatureEntry">
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s" data-test-subj="subFeatureName">
{subFeature.name}
</EuiText>
</EuiFlexItem>
{props.effectiveFeaturePrivileges.map((privs, index) => {
return (
<EuiFlexItem key={index} data-test-subj={`entry-${index}`}>
{subFeature.getPrivilegeGroups().map(renderPrivilegeGroup(privs.subFeature))}
</EuiFlexItem>
);
})}
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
function renderPrivilegeGroup(effectiveSubFeaturePrivileges: string[]) {
return (privilegeGroup: SubFeaturePrivilegeGroup, index: number) => {
switch (privilegeGroup.groupType) {
case 'independent':
return renderIndependentPrivilegeGroup(
effectiveSubFeaturePrivileges,
privilegeGroup,
index
);
case 'mutually_exclusive':
return renderMutuallyExclusivePrivilegeGroup(
effectiveSubFeaturePrivileges,
privilegeGroup,
index
);
default:
throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`);
}
};
}
function renderIndependentPrivilegeGroup(
effectiveSubFeaturePrivileges: string[],
privilegeGroup: SubFeaturePrivilegeGroup,
index: number
) {
return (
<div key={index}>
{privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => {
const isGranted = effectiveSubFeaturePrivileges.includes(privilege.id);
return (
<EuiFlexGroup gutterSize="s" data-test-subj="independentPrivilege" key={privilege.id}>
<EuiFlexItem grow={false}>
<EuiIconTip
type={isGranted ? 'check' : 'cross'}
color={isGranted ? 'primary' : 'danger'}
content={
isGranted
? i18n.translate(
'xpack.security.management.editRole.privilegeSummary.privilegeGrantedIconTip',
{ defaultMessage: 'Privilege is granted' }
)
: i18n.translate(
'xpack.security.management.editRole.privilegeSummary.privilegeNotGrantedIconTip',
{ defaultMessage: 'Privilege is not granted' }
)
}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" data-test-subj="privilegeName">
{privilege.name}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
})}
</div>
);
}
function renderMutuallyExclusivePrivilegeGroup(
effectiveSubFeaturePrivileges: string[],
privilegeGroup: SubFeaturePrivilegeGroup,
index: number
) {
const firstSelectedPrivilege = privilegeGroup.privileges.find(p =>
effectiveSubFeaturePrivileges.includes(p.id)
)?.name;
return (
<EuiFlexGroup gutterSize="s" key={index} data-test-subj="mutexPrivilege">
<EuiFlexItem grow={false}>
<EuiIconTip
type={firstSelectedPrivilege ? 'check' : 'cross'}
color={firstSelectedPrivilege ? 'primary' : 'danger'}
content={firstSelectedPrivilege ? 'Privilege is granted' : 'Privilege is not granted'}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" data-test-subj="privilegeName">
{firstSelectedPrivilege ?? 'None'}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
};

View file

@ -0,0 +1,922 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges';
import { kibanaFeatures } from '../../../../__fixtures__/kibana_features';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { PrivilegeSummaryTable } from './privilege_summary_table';
import { RoleKibanaPrivilege } from '../../../../../../../common/model';
import { getDisplayedFeaturePrivileges } from './__fixtures__';
const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({
name: 'some-role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: roleKibanaPrivileges,
});
const spaces = [
{
id: 'default',
name: 'Default Space',
disabledFeatures: [],
},
{
id: 'space-1',
name: 'First Space',
disabledFeatures: [],
},
{
id: 'space-2',
name: 'Second Space',
disabledFeatures: [],
},
];
const maybeExpectSubFeaturePrivileges = (expect: boolean, subFeaturesPrivileges: unknown) => {
return expect ? { subFeaturesPrivileges } : {};
};
const expectNoPrivileges = (displayedPrivileges: any, expectSubFeatures: boolean) => {
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(expectSubFeatures, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(expectSubFeatures, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(expectSubFeatures, {
'Cool Sub Feature': [],
}),
},
},
});
};
describe('PrivilegeSummaryTable', () => {
[true, false].forEach(allowSubFeaturePrivileges => {
describe(`when sub feature privileges are ${
allowSubFeaturePrivileges ? 'allowed' : 'disallowed'
}`, () => {
it('ignores unknown base privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['idk_what_this_means'],
feature: {},
spaces: ['*'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges);
});
it('ignores unknown feature privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['this_doesnt_exist_either'],
},
spaces: ['*'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges);
});
it('ignores unknown features', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: [],
feature: {
unknown_feature: ['this_doesnt_exist_either'],
},
spaces: ['*'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges);
});
it('renders effective privileges for the global base privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['all'],
feature: {},
spaces: ['*'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for a global feature privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['all'],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['*'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for the space base privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['all'],
feature: {},
spaces: ['default', 'space-1'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
},
},
with_excluded_sub_features: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for a space feature privilege', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
},
spaces: ['default', 'space-1'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
},
},
with_excluded_sub_features: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'default, space-1': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
});
});
it('renders effective privileges for global base + space base privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['read'],
feature: {},
spaces: ['*'],
},
{
base: ['all'],
feature: {},
spaces: ['default', 'space-1'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2', 'Read'],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for global base + space feature privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['read'],
feature: {},
spaces: ['*'],
},
{
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_all'],
},
spaces: ['default', 'space-1'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2', 'Read'],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for global feature + space base privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_all'],
},
spaces: ['*'],
},
{
base: ['read'],
feature: {},
spaces: ['default', 'space-1'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['All'],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for global feature + space feature privileges', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: [],
feature: {
with_sub_features: ['minimal_read', 'cool_all'],
},
spaces: ['*'],
},
{
base: [],
feature: {
with_sub_features: ['all'],
},
spaces: ['default', 'space-1'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['All'],
}),
},
'default, space-1': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'],
}),
},
},
});
});
it('renders effective privileges for a complex setup', () => {
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, {
allowSubFeaturePrivileges,
});
const role = createRole([
{
base: ['read'],
feature: {},
spaces: ['*'],
},
{
base: ['read', 'all'],
feature: {},
spaces: ['default'],
},
{
base: [],
feature: {
with_sub_features: ['minimal_read'],
with_excluded_sub_features: ['all', 'cool_toggle_1'],
no_sub_features: ['all'],
excluded_from_base: ['minimal_all', 'cool_toggle_1'],
},
spaces: ['space-1', 'space-2'],
},
]);
const wrapper = mountWithIntl(
<PrivilegeSummaryTable
spaces={spaces}
kibanaPrivileges={kibanaPrivileges}
role={role}
canCustomizeSubFeaturePrivileges={allowSubFeaturePrivileges}
/>
);
const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role);
expect(displayedPrivileges).toEqual({
excluded_from_base: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
default: {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': [],
}),
},
'space-1, space-2': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'All' : 'None',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2'],
}),
},
},
no_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
},
default: {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
},
'space-1, space-2': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
},
},
with_excluded_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
default: {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': [],
}),
},
'space-1, space-2': {
hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Excluded Sub Feature': ['Cool toggle 1'],
}),
},
},
with_sub_features: {
'*': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2', 'Read'],
}),
},
default: {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'All',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'],
}),
},
'space-1, space-2': {
hasCustomizedSubFeaturePrivileges: false,
primaryFeaturePrivilege: 'Read',
...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, {
'Cool Sub Feature': ['Cool toggle 2', 'Read'],
}),
},
},
});
});
});
});
});

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiInMemoryTable,
EuiBasicTableColumn,
EuiButtonIcon,
EuiIcon,
EuiIconTip,
} from '@elastic/eui';
import { Space } from '../../../../../../../../spaces/common/model/space';
import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { FeatureTableCell } from '../feature_table_cell';
import { SpaceColumnHeader } from './space_column_header';
import { PrivilegeSummaryExpandedRow } from './privilege_summary_expanded_row';
import { SecuredFeature, KibanaPrivileges } from '../../../../model';
import {
PrivilegeSummaryCalculator,
EffectiveFeaturePrivileges,
} from './privilege_summary_calculator';
interface Props {
role: Role;
spaces: Space[];
kibanaPrivileges: KibanaPrivileges;
canCustomizeSubFeaturePrivileges: boolean;
}
function getColumnKey(entry: RoleKibanaPrivilege) {
return `privilege_entry_${entry.spaces.join('|')}`;
}
export const PrivilegeSummaryTable = (props: Props) => {
const [expandedFeatures, setExpandedFeatures] = useState<string[]>([]);
const calculator = new PrivilegeSummaryCalculator(props.kibanaPrivileges, props.role);
const toggleExpandedFeature = (featureId: string) => {
if (expandedFeatures.includes(featureId)) {
setExpandedFeatures(expandedFeatures.filter(ef => ef !== featureId));
} else {
setExpandedFeatures([...expandedFeatures, featureId]);
}
};
const featureColumn: EuiBasicTableColumn<any> = {
name: 'Feature',
field: 'feature',
render: (feature: any) => {
return <FeatureTableCell feature={feature} />;
},
};
const rowExpanderColumn: EuiBasicTableColumn<any> = {
align: 'right',
width: '40px',
isExpander: true,
field: 'featureId',
name: '',
render: (featureId: string, record: any) => {
const feature = record.feature as SecuredFeature;
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
if (!hasSubFeaturePrivileges) {
return null;
}
return (
<EuiButtonIcon
onClick={() => toggleExpandedFeature(featureId)}
data-test-subj={`expandPrivilegeSummaryRow`}
aria-label={expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'}
iconType={expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'}
/>
);
},
};
const rawKibanaPrivileges = [...props.role.kibana].sort((entry1, entry2) => {
if (isGlobalPrivilegeDefinition(entry1)) {
return -1;
}
if (isGlobalPrivilegeDefinition(entry2)) {
return 1;
}
return 0;
});
const privilegeColumns = rawKibanaPrivileges.map(entry => {
const key = getColumnKey(entry);
return {
name: <SpaceColumnHeader entry={entry} spaces={props.spaces} />,
field: key,
render: (kibanaPrivilege: EffectiveFeaturePrivileges, record: { featureId: string }) => {
const { primary, hasCustomizedSubFeaturePrivileges } = kibanaPrivilege[record.featureId];
let iconTip = null;
if (hasCustomizedSubFeaturePrivileges) {
iconTip = (
<EuiIconTip
size="s"
type="iInCircle"
content={
<span>
<FormattedMessage
id="xpack.security.management.editRole.privilegeSummary.additionalPrivilegesGrantedIconTip"
defaultMessage="Additional privileges granted. Expand this row for more information."
/>
</span>
}
/>
);
} else {
iconTip = <EuiIcon size="s" type="empty" />;
}
return (
<span
data-test-subj={`privilegeColumn ${
hasCustomizedSubFeaturePrivileges ? 'additionalPrivilegesGranted' : ''
}`}
>
{primary?.name ?? 'None'} {iconTip}
</span>
);
},
};
});
const columns: Array<EuiBasicTableColumn<any>> = [];
if (props.canCustomizeSubFeaturePrivileges) {
columns.push(rowExpanderColumn);
}
columns.push(featureColumn, ...privilegeColumns);
const privileges = rawKibanaPrivileges.reduce((acc, entry) => {
return {
...acc,
[getColumnKey(entry)]: calculator.getEffectiveFeaturePrivileges(entry),
};
}, {} as Record<string, EffectiveFeaturePrivileges>);
const items = props.kibanaPrivileges.getSecuredFeatures().map(feature => {
return {
feature,
featureId: feature.id,
...privileges,
};
});
return (
<EuiInMemoryTable
columns={columns}
items={items}
itemId="featureId"
rowProps={record => {
return {
'data-test-subj': `summaryTableRow-${record.featureId}`,
};
}}
itemIdToExpandedRowMap={expandedFeatures.reduce((acc, featureId) => {
return {
...acc,
[featureId]: (
<PrivilegeSummaryExpandedRow
feature={props.kibanaPrivileges.getSecuredFeature(featureId)}
effectiveFeaturePrivileges={Object.values(privileges).map(p => p[featureId])}
/>
),
};
}, {})}
/>
);
};

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { SpaceColumnHeader } from './space_column_header';
import { SpacesPopoverList } from '../../../spaces_popover_list';
import { SpaceAvatar } from '../../../../../../../../spaces/public';
const spaces = [
{
id: '*',
name: 'Global',
disabledFeatures: [],
},
{
id: 'space-1',
name: 'Space 1',
disabledFeatures: [],
},
{
id: 'space-2',
name: 'Space 2',
disabledFeatures: [],
},
{
id: 'space-3',
name: 'Space 3',
disabledFeatures: [],
},
{
id: 'space-4',
name: 'Space 4',
disabledFeatures: [],
},
{
id: 'space-5',
name: 'Space 5',
disabledFeatures: [],
},
];
describe('SpaceColumnHeader', () => {
it('renders the Global privilege definition with a special label and popover control', () => {
const wrapper = mountWithIntl(
<SpaceColumnHeader
spaces={spaces}
entry={{
base: [],
feature: {},
spaces: ['*'],
}}
/>
);
expect(wrapper.find(SpacesPopoverList)).toHaveLength(1);
// Snapshot includes space avatar (The first "G"), followed by the "Global" label,
// followed by the (all spaces) text as part of the SpacesPopoverList
expect(wrapper.text()).toMatchInlineSnapshot(`"G Global(all spaces)"`);
});
it('renders a placeholder space when the requested space no longer exists', () => {
const wrapper = mountWithIntl(
<SpaceColumnHeader
spaces={spaces}
entry={{
base: [],
feature: {},
spaces: ['space-1', 'missing-space', 'space-3'],
}}
/>
);
expect(wrapper.find(SpacesPopoverList)).toHaveLength(0);
const avatars = wrapper.find(SpaceAvatar);
expect(avatars).toHaveLength(3);
expect(wrapper.text()).toMatchInlineSnapshot(`"S1 m S3 "`);
});
it('renders a space privilege definition with an avatar for each space in the group', () => {
const wrapper = mountWithIntl(
<SpaceColumnHeader
spaces={spaces}
entry={{
base: [],
feature: {},
spaces: ['space-1', 'space-2', 'space-3', 'space-4'],
}}
/>
);
expect(wrapper.find(SpacesPopoverList)).toHaveLength(0);
const avatars = wrapper.find(SpaceAvatar);
expect(avatars).toHaveLength(4);
expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 "`);
});
it('renders a space privilege definition with an avatar for the first 4 spaces in the group, with the popover control showing the rest', () => {
const wrapper = mountWithIntl(
<SpaceColumnHeader
spaces={spaces}
entry={{
base: [],
feature: {},
spaces: ['space-1', 'space-2', 'space-3', 'space-4', 'space-5'],
}}
/>
);
expect(wrapper.find(SpacesPopoverList)).toHaveLength(1);
const avatars = wrapper.find(SpaceAvatar);
expect(avatars).toHaveLength(4);
expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 +1 more"`);
});
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { Space, SpaceAvatar } from '../../../../../../../../spaces/public';
import { RoleKibanaPrivilege } from '../../../../../../../common/model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { SpacesPopoverList } from '../../../spaces_popover_list';
interface Props {
spaces: Space[];
entry: RoleKibanaPrivilege;
}
const SPACES_DISPLAY_COUNT = 4;
export const SpaceColumnHeader = (props: Props) => {
const isGlobal = isGlobalPrivilegeDefinition(props.entry);
const entrySpaces = props.entry.spaces.map(spaceId => {
return (
props.spaces.find(s => s.id === spaceId) ?? {
id: spaceId,
name: spaceId,
disabledFeatures: [],
}
);
});
return (
<div>
{entrySpaces.slice(0, SPACES_DISPLAY_COUNT).map(space => {
return (
<span key={space.id}>
<SpaceAvatar size="s" space={space} />{' '}
{isGlobal && (
<span>
<FormattedMessage
id="xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName"
defaultMessage="Global"
/>
<br />
<SpacesPopoverList
spaces={props.spaces.filter(s => s.id !== '*')}
buttonText={i18n.translate(
'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink',
{
defaultMessage: '(all spaces)',
}
)}
/>
</span>
)}
</span>
);
})}
{entrySpaces.length > SPACES_DISPLAY_COUNT && (
<Fragment>
<br />
<SpacesPopoverList
spaces={entrySpaces}
buttonText={i18n.translate(
'xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink',
{
defaultMessage: '+{count} more',
values: {
count: entrySpaces.length - SPACES_DISPLAY_COUNT,
},
}
)}
/>
</Fragment>
)}
</div>
);
};

View file

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

View file

@ -7,24 +7,53 @@
import { EuiButtonGroup, EuiButtonGroupProps, EuiComboBox, EuiSuperSelect } from '@elastic/eui';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { Feature } from '../../../../../../../../features/public';
import { KibanaPrivileges, Role } from '../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator';
import { Role } from '../../../../../../../common/model';
import { SimplePrivilegeSection } from './simple_privilege_section';
import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning';
import { KibanaPrivileges, SecuredFeature } from '../../../../model';
const buildProps = (customProps: any = {}) => {
const kibanaPrivileges = new KibanaPrivileges({
features: {
feature1: {
all: ['*'],
read: ['read'],
const features = [
new SecuredFeature({
id: 'feature1',
name: 'Feature 1',
app: ['app'],
icon: 'spacesApp',
privileges: {
all: {
app: ['app'],
savedObject: {
all: ['foo'],
read: [],
},
ui: ['app-ui'],
},
read: {
app: ['app'],
savedObject: {
all: [],
read: [],
},
ui: ['app-ui'],
},
},
}),
] as SecuredFeature[];
const kibanaPrivileges = new KibanaPrivileges(
{
features: {
feature1: {
all: ['*'],
read: ['read'],
},
},
global: {},
space: {},
reserved: {},
},
global: {},
space: {},
reserved: {},
});
features
);
const role = {
name: '',
@ -40,34 +69,9 @@ const buildProps = (customProps: any = {}) => {
return {
editable: true,
kibanaPrivileges,
privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges),
features: [
{
id: 'feature1',
name: 'Feature 1',
app: ['app'],
icon: 'spacesApp',
privileges: {
all: {
app: ['app'],
savedObject: {
all: ['foo'],
read: [],
},
ui: ['app-ui'],
},
read: {
app: ['app'],
savedObject: {
all: [],
read: [],
},
ui: ['app-ui'],
},
},
},
] as Feature[],
features,
onChange: jest.fn(),
canCustomizeSubFeaturePrivileges: true,
...customProps,
role,
};

View file

@ -6,34 +6,28 @@
import {
EuiComboBox,
EuiDescribedFormGroup,
EuiFormRow,
EuiSuperSelect,
EuiText,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Component, Fragment } from 'react';
import { Feature } from '../../../../../../../../features/public';
import {
KibanaPrivileges,
Role,
RoleKibanaPrivilege,
copyRole,
} from '../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator';
import { Role, RoleKibanaPrivilege, copyRole } from '../../../../../../../common/model';
import { isGlobalPrivilegeDefinition } from '../../../privilege_utils';
import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants';
import { FeatureTable } from '../feature_table';
import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning';
import { KibanaPrivileges } from '../../../../model';
import { PrivilegeFormCalculator } from '../privilege_form_calculator';
interface Props {
role: Role;
kibanaPrivileges: KibanaPrivileges;
privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory;
features: Feature[];
onChange: (role: Role) => void;
editable: boolean;
canCustomizeSubFeaturePrivileges: boolean;
}
interface State {
@ -58,20 +52,14 @@ export class SimplePrivilegeSection extends Component<Props, State> {
public render() {
const kibanaPrivilege = this.getDisplayedBasePrivilege();
const privilegeCalculator = this.props.privilegeCalculatorFactory.getInstance(this.props.role);
const reservedPrivileges = this.props.role.kibana[this.state.globalPrivsIndex]?._reserved ?? [];
const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[
this.state.globalPrivsIndex
];
const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[
this.state.globalPrivsIndex
];
const hasReservedPrivileges =
calculatedPrivileges &&
calculatedPrivileges.reserved != null &&
calculatedPrivileges.reserved.length > 0;
const title = (
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.kibanaPrivilegesTitle"
defaultMessage="Kibana privileges"
/>
);
const description = (
<p>
@ -84,162 +72,159 @@ export class SimplePrivilegeSection extends Component<Props, State> {
return (
<Fragment>
<EuiDescribedFormGroup
title={
<h3>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.kibanaPrivilegesTitle"
defaultMessage="Kibana privileges"
/>
</h3>
}
description={description}
>
<EuiFormRow hasEmptyLabelSpace>
{hasReservedPrivileges ? (
<EuiComboBox
fullWidth
selectedOptions={calculatedPrivileges.reserved!.map(privilege => ({
label: privilege,
}))}
isDisabled
/>
) : (
<EuiSuperSelect
disabled={!this.props.editable}
onChange={this.onKibanaPrivilegeChange}
options={[
{
value: NO_PRIVILEGE_VALUE,
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeInput"
defaultMessage="None"
/>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiText size="s" color="subdued">
{description}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label={title}>
{reservedPrivileges.length > 0 ? (
<EuiComboBox
fullWidth
selectedOptions={reservedPrivileges.map(rp => ({ label: rp }))}
isDisabled
/>
) : (
<EuiSuperSelect
disabled={!this.props.editable}
onChange={this.onKibanaPrivilegeChange}
options={[
{
value: NO_PRIVILEGE_VALUE,
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdown"
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeInput"
defaultMessage="None"
/>
</strong>
<p>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdown"
defaultMessage="None"
/>
</strong>
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdownDescription"
defaultMessage="No access to Kibana"
/>
</p>
</EuiText>
),
},
{
value: CUSTOM_PRIVILEGE_VALUE,
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.noPrivilegeDropdownDescription"
defaultMessage="No access to Kibana"
/>
</p>
</EuiText>
),
},
{
value: CUSTOM_PRIVILEGE_VALUE,
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeInput"
defaultMessage="Custom"
/>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdown"
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeInput"
defaultMessage="Custom"
/>
</strong>
<p>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdown"
defaultMessage="Custom"
/>
</strong>
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdownDescription"
defaultMessage="Customize access to Kibana"
/>
</p>
</EuiText>
),
},
{
value: 'read',
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.customPrivilegeDropdownDescription"
defaultMessage="Customize access to Kibana"
/>
</p>
</EuiText>
),
},
{
value: 'read',
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput"
defaultMessage="Read"
/>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdown"
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput"
defaultMessage="Read"
/>
</strong>
<p>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdown"
defaultMessage="Read"
/>
</strong>
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdownDescription"
defaultMessage="Grants read-only access to all of Kibana"
/>
</p>
</EuiText>
),
},
{
value: 'all',
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeDropdownDescription"
defaultMessage="Grants read-only access to all of Kibana"
/>
</p>
</EuiText>
),
},
{
value: 'all',
inputDisplay: (
<EuiText>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeInput"
defaultMessage="All"
/>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdown"
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeInput"
defaultMessage="All"
/>
</strong>
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdownDescription"
defaultMessage="Grants full access to all of Kibana"
/>
</p>
</EuiText>
),
},
]}
hasDividers
valueOfSelected={kibanaPrivilege}
/>
)}
</EuiFormRow>
{this.state.isCustomizingGlobalPrivilege && (
<EuiFormRow>
<FeatureTable
role={this.props.role}
kibanaPrivileges={this.props.kibanaPrivileges}
calculatedPrivileges={calculatedPrivileges}
allowedPrivileges={allowedPrivileges}
rankedFeaturePrivileges={privilegeCalculator.rankedFeaturePrivileges}
features={this.props.features}
onChange={this.onFeaturePrivilegeChange}
onChangeAll={this.onChangeAllFeaturePrivileges}
spacesIndex={this.props.role.kibana.findIndex(k => isGlobalPrivilegeDefinition(k))}
/>
</EuiText>
),
dropdownDisplay: (
<EuiText>
<strong>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdown"
defaultMessage="All"
/>
</strong>
<p>
<FormattedMessage
id="xpack.security.management.editRole.simplePrivilegeForm.allPrivilegeDropdownDescription"
defaultMessage="Grants full access to all of Kibana"
/>
</p>
</EuiText>
),
},
]}
hasDividers
valueOfSelected={kibanaPrivilege}
/>
)}
</EuiFormRow>
)}
{this.maybeRenderSpacePrivilegeWarning()}
</EuiDescribedFormGroup>
{this.state.isCustomizingGlobalPrivilege && (
<EuiFormRow fullWidth>
<FeatureTable
role={this.props.role}
kibanaPrivileges={this.props.kibanaPrivileges}
privilegeCalculator={
new PrivilegeFormCalculator(this.props.kibanaPrivileges, this.props.role)
}
onChange={this.onFeaturePrivilegeChange}
onChangeAll={this.onChangeAllFeaturePrivileges}
privilegeIndex={this.props.role.kibana.findIndex(k =>
isGlobalPrivilegeDefinition(k)
)}
canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges}
/>
</EuiFormRow>
)}
{this.maybeRenderSpacePrivilegeWarning()}
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
@ -295,7 +280,7 @@ export class SimplePrivilegeSection extends Component<Props, State> {
const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role);
if (privileges.length > 0) {
this.props.features.forEach(feature => {
this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => {
form.feature[feature.id] = [...privileges];
});
} else {

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RawKibanaPrivileges } from '../../../../../../../../common/model';
export const rawKibanaPrivileges: RawKibanaPrivileges = {
global: {
all: [
'normal-feature-all',
'normal-feature-read',
'just-global-all',
'all-privilege-excluded-from-base-read',
],
read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'],
},
space: {
all: ['normal-feature-all', 'normal-feature-read', 'all-privilege-excluded-from-base-read'],
read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'],
},
reserved: {},
features: {
normal: {
all: ['normal-feature-all', 'normal-feature-read'],
read: ['normal-feature-read'],
},
bothPrivilegesExcludedFromBase: {
all: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'],
read: ['both-privileges-excluded-from-base-read'],
},
allPrivilegeExcludedFromBase: {
all: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'],
read: ['all-privilege-excluded-from-base-read'],
},
},
};

View file

@ -1,118 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PrivilegeDisplay renders a superceded privilege 1`] = `
<SupersededPrivilegeDisplay
explanation={
Object {
"actualPrivilege": "all",
"actualPrivilegeSource": 40,
"isDirectlyAssigned": false,
"supersededPrivilege": "read",
"supersededPrivilegeSource": 20,
}
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
privilege="all"
/>
`;

View file

@ -1,497 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PrivilegeSpaceForm> renders without crashing 1`] = `
<EuiOverlayMask>
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={true}
onClose={[Function]}
ownFocus={false}
size="m"
>
<EuiFlyoutHeader
hasBorder={true}
>
<EuiTitle
size="m"
>
<h2>
<FormattedMessage
defaultMessage="Space privileges"
id="xpack.security.management.editRole.spacePrivilegeForm.modalTitle"
values={Object {}}
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Spaces"
labelType="label"
>
<SpaceSelector
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
onChange={[Function]}
selectedSpaceIds={Array []}
spaces={
Array [
Object {
"_reserved": true,
"description": "",
"disabledFeatures": Array [],
"id": "default",
"name": "Default Space",
},
Object {
"description": "",
"disabledFeatures": Array [],
"id": "marketing",
"name": "Marketing",
},
]
}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Privilege"
labelType="label"
>
<EuiSuperSelect
compressed={false}
data-test-subj="basePrivilegeComboBox"
disabled={true}
fullWidth={true}
hasDividers={true}
isInvalid={false}
isLoading={false}
onChange={[Function]}
options={
Array [
Object {
"disabled": false,
"dropdownDisplay": <EuiText>
<strong>
<FormattedMessage
defaultMessage="Custom"
id="xpack.security.management.editRole.spacePrivilegeForm.customPrivilegeDropdownDisplay"
values={Object {}}
/>
</strong>
<p>
<FormattedMessage
defaultMessage="Customize access by feature in selected spaces."
id="xpack.security.management.editRole.spacePrivilegeForm.customPrivilegeDetails"
values={Object {}}
/>
</p>
</EuiText>,
"inputDisplay": <EuiText>
<FormattedMessage
defaultMessage="Custom"
id="xpack.security.management.editRole.spacePrivilegeForm.customPrivilegeDisplay"
values={Object {}}
/>
</EuiText>,
"value": "basePrivilege_custom",
},
Object {
"disabled": false,
"dropdownDisplay": <EuiText>
<strong>
<FormattedMessage
defaultMessage="Read"
id="xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay"
values={Object {}}
/>
</strong>
<p>
<FormattedMessage
defaultMessage="Grant read-only access to all features in selected spaces."
id="xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDetails"
values={Object {}}
/>
</p>
</EuiText>,
"inputDisplay": <EuiText>
<FormattedMessage
defaultMessage="Read"
id="xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDisplay"
values={Object {}}
/>
</EuiText>,
"value": "basePrivilege_read",
},
Object {
"dropdownDisplay": <EuiText>
<strong>
<FormattedMessage
defaultMessage="All"
id="xpack.security.management.editRole.spacePrivilegeForm.allPrivilegeDropdownDisplay"
values={Object {}}
/>
</strong>
<p>
<FormattedMessage
defaultMessage="Grant full access to all features in selected spaces."
id="xpack.security.management.editRole.spacePrivilegeForm.allPrivilegeDetails"
values={Object {}}
/>
</p>
</EuiText>,
"inputDisplay": <EuiText>
<FormattedMessage
defaultMessage="All"
id="xpack.security.management.editRole.spacePrivilegeForm.allPrivilegeDisplay"
values={Object {}}
/>
</EuiText>,
"value": "basePrivilege_all",
},
]
}
valueOfSelected="basePrivilege_custom"
/>
</EuiFormRow>
<EuiSpacer
size="s"
/>
<EuiTitle
size="xxs"
>
<h3>
Customize by feature
</h3>
</EuiTitle>
<EuiSpacer
size="xs"
/>
<EuiText
size="s"
>
<p>
Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.
</p>
</EuiText>
<EuiSpacer
size="l"
/>
<FeatureTable
allowedPrivileges={
Object {
"base": Object {
"canUnassign": true,
"privileges": Array [
"all",
"read",
],
},
"feature": Object {
"allPrivilegeExcludedFromBase": Object {
"canUnassign": true,
"privileges": Array [
"all",
"read",
],
},
"bothPrivilegesExcludedFromBase": Object {
"canUnassign": true,
"privileges": Array [
"all",
"read",
],
},
"normal": Object {
"canUnassign": true,
"privileges": Array [
"all",
"read",
],
},
},
}
}
calculatedPrivileges={
Object {
"base": Object {
"actualPrivilege": "none",
"actualPrivilegeSource": 40,
"isDirectlyAssigned": true,
},
"feature": Object {
"allPrivilegeExcludedFromBase": Object {
"actualPrivilege": "none",
"actualPrivilegeSource": 30,
"isDirectlyAssigned": true,
},
"bothPrivilegesExcludedFromBase": Object {
"actualPrivilege": "none",
"actualPrivilegeSource": 30,
"isDirectlyAssigned": true,
},
"normal": Object {
"actualPrivilege": "none",
"actualPrivilegeSource": 30,
"isDirectlyAssigned": true,
},
},
"reserved": undefined,
}
}
disabled={true}
features={Array []}
kibanaPrivileges={
KibanaPrivileges {
"rawKibanaPrivileges": Object {
"features": Object {
"allPrivilegeExcludedFromBase": Object {
"all": Array [
"all-privilege-excluded-from-base-all",
"all-privilege-excluded-from-base-read",
],
"read": Array [
"all-privilege-excluded-from-base-read",
],
},
"bothPrivilegesExcludedFromBase": Object {
"all": Array [
"both-privileges-excluded-from-base-all",
"both-privileges-excluded-from-base-read",
],
"read": Array [
"both-privileges-excluded-from-base-read",
],
},
"normal": Object {
"all": Array [
"normal-feature-all",
"normal-feature-read",
],
"read": Array [
"normal-feature-read",
],
},
},
"global": Object {
"all": Array [
"normal-feature-all",
"normal-feature-read",
"just-global-all",
"all-privilege-excluded-from-base-read",
],
"read": Array [
"normal-feature-read",
"all-privilege-excluded-from-base-read",
],
},
"reserved": Object {},
"space": Object {
"all": Array [
"normal-feature-all",
"normal-feature-read",
"all-privilege-excluded-from-base-read",
],
"read": Array [
"normal-feature-read",
"all-privilege-excluded-from-base-read",
],
},
},
}
}
onChange={[Function]}
onChangeAll={[Function]}
rankedFeaturePrivileges={
Object {
"allPrivilegeExcludedFromBase": Array [
"all",
"read",
],
"bothPrivilegesExcludedFromBase": Array [
"all",
"read",
],
"normal": Array [
"all",
"read",
],
}
}
role={
Object {
"elasticsearch": Object {
"cluster": Array [
"all",
],
"indices": Array [],
"run_as": Array [],
},
"kibana": Array [
Object {
"base": Array [],
"feature": Object {},
"spaces": Array [],
},
],
"name": "test role",
}
}
showLocks={true}
spacesIndex={0}
/>
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup
justifyContent="spaceBetween"
>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
data-test-subj="cancelSpacePrivilegeButton"
flush="left"
iconType="cross"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="xpack.security.management.editRolespacePrivilegeForm.cancelButton"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
data-test-subj="createSpacePrivilegeButton"
disabled={true}
fill={true}
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Update space privilege"
id="xpack.security.management.editRolespacePrivilegeForm.updatePrivilegeButton"
values={Object {}}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiOverlayMask>
`;

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiIconTip, EuiText, EuiToolTip } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { PRIVILEGE_SOURCE } from '../kibana_privilege_calculator';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { PrivilegeDisplay } from './privilege_display';
describe('PrivilegeDisplay', () => {
@ -23,41 +22,4 @@ describe('PrivilegeDisplay', () => {
color: 'danger',
});
});
it('renders a privilege with tooltip, if provided', () => {
const wrapper = mountWithIntl(
<PrivilegeDisplay privilege={'all'} tooltipContent={<b>ahh</b>} />
);
expect(wrapper.text().trim()).toEqual('All');
expect(wrapper.find(EuiToolTip).props()).toMatchObject({
content: <b>ahh</b>,
});
});
it('renders a privilege with icon tooltip, if provided', () => {
const wrapper = mountWithIntl(
<PrivilegeDisplay privilege={'all'} iconTooltipContent={<b>ahh</b>} iconType={'asterisk'} />
);
expect(wrapper.text().trim()).toEqual('All');
expect(wrapper.find(EuiIconTip).props()).toMatchObject({
type: 'asterisk',
content: <b>ahh</b>,
});
});
it('renders a superceded privilege', () => {
const wrapper = shallowWithIntl(
<PrivilegeDisplay
privilege={'all'}
explanation={{
supersededPrivilege: 'read',
supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE,
actualPrivilege: 'all',
actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE,
isDirectlyAssigned: false,
}}
/>
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -3,95 +3,28 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiIcon, EuiIconTip, EuiText, IconType, PropsOf, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiIcon, EuiText, PropsOf } from '@elastic/eui';
import _ from 'lodash';
import React, { ReactNode, FC } from 'react';
import { PRIVILEGE_SOURCE, PrivilegeExplanation } from '../kibana_privilege_calculator';
import { NO_PRIVILEGE_VALUE } from '../constants';
interface Props extends PropsOf<typeof EuiText> {
privilege: string | string[] | undefined;
explanation?: PrivilegeExplanation;
iconType?: IconType;
iconTooltipContent?: ReactNode;
tooltipContent?: ReactNode;
'data-test-subj'?: string;
}
export const PrivilegeDisplay: FC<Props> = (props: Props) => {
const { explanation } = props;
if (!explanation) {
return <SimplePrivilegeDisplay {...props} />;
}
if (explanation.supersededPrivilege) {
return <SupersededPrivilegeDisplay {...props} />;
}
if (!explanation.isDirectlyAssigned) {
return <EffectivePrivilegeDisplay {...props} />;
}
return <SimplePrivilegeDisplay {...props} />;
};
const SimplePrivilegeDisplay: FC<Props> = (props: Props) => {
const { privilege, iconType, iconTooltipContent, explanation, tooltipContent, ...rest } = props;
const { privilege, ...rest } = props;
const text = (
<EuiText {...rest}>
{getDisplayValue(privilege)} {getIconTip(iconType, iconTooltipContent)}
</EuiText>
);
if (tooltipContent) {
return <EuiToolTip content={tooltipContent}>{text}</EuiToolTip>;
}
const text = <EuiText {...rest}>{getDisplayValue(privilege)}</EuiText>;
return text;
};
export const SupersededPrivilegeDisplay: FC<Props> = (props: Props) => {
const { supersededPrivilege, actualPrivilegeSource } =
props.explanation || ({} as PrivilegeExplanation);
return (
<SimplePrivilegeDisplay
{...props}
iconType={'lock'}
iconTooltipContent={
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage"
defaultMessage="Original privilege of {supersededPrivilege} has been overriden by {actualPrivilegeSource}"
values={{
supersededPrivilege: `'${supersededPrivilege}'`,
actualPrivilegeSource: getReadablePrivilegeSource(actualPrivilegeSource),
}}
/>
}
/>
);
};
export const EffectivePrivilegeDisplay: FC<Props> = (props: Props) => {
const { explanation, ...rest } = props;
const source = getReadablePrivilegeSource(explanation!.actualPrivilegeSource);
const iconTooltipContent = (
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage"
defaultMessage="Granted via {source}."
values={{ source }}
/>
);
return (
<SimplePrivilegeDisplay {...rest} iconType={'lock'} iconTooltipContent={iconTooltipContent} />
);
};
PrivilegeDisplay.defaultProps = {
privilege: [],
};
@ -113,24 +46,6 @@ function getDisplayValue(privilege: string | string[] | undefined) {
return displayValue;
}
function getIconTip(iconType?: IconType, tooltipContent?: ReactNode) {
if (!iconType || !tooltipContent) {
return null;
}
return (
<EuiIconTip
iconProps={{
className: 'eui-alignTop',
}}
color="subdued"
type={iconType}
content={tooltipContent}
size={'s'}
/>
);
}
function coerceToArray(privilege: string | string[] | undefined): string[] {
if (privilege === undefined) {
return [];
@ -140,43 +55,3 @@ function coerceToArray(privilege: string | string[] | undefined): string[] {
}
return [privilege];
}
function getReadablePrivilegeSource(privilegeSource: PRIVILEGE_SOURCE) {
switch (privilegeSource) {
case PRIVILEGE_SOURCE.GLOBAL_BASE:
return (
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource"
defaultMessage="global base privilege"
/>
);
case PRIVILEGE_SOURCE.GLOBAL_FEATURE:
return (
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource"
defaultMessage="global feature privilege"
/>
);
case PRIVILEGE_SOURCE.SPACE_BASE:
return (
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource"
defaultMessage="space base privilege"
/>
);
case PRIVILEGE_SOURCE.SPACE_FEATURE:
return (
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource"
defaultMessage="space feature privilege"
/>
);
default:
return (
<FormattedMessage
id="xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource"
defaultMessage="**UNKNOWN**"
/>
);
}
}

View file

@ -1,128 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButtonEmpty, EuiInMemoryTable } from '@elastic/eui';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { Space } from '../../../../../../../../spaces/public';
import { Feature } from '../../../../../../../../features/public';
import { KibanaPrivileges, Role } from '../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator';
import { PrivilegeMatrix } from './privilege_matrix';
describe('PrivilegeMatrix', () => {
it('can render a complex matrix', () => {
const spaces: Space[] = ['*', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].map(a => ({
id: a,
name: `${a} space`,
disabledFeatures: [],
}));
const features: Feature[] = [
{
id: 'feature1',
name: 'feature 1',
icon: 'apmApp',
app: [],
privileges: {},
},
{
id: 'feature2',
name: 'feature 2',
icon: 'apmApp',
app: [],
privileges: {},
},
{
id: 'feature3',
name: 'feature 3',
icon: 'apmApp',
app: [],
privileges: {},
},
];
const role: Role = {
name: 'role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
spaces: ['*'],
base: ['read'],
feature: {
feature1: ['all'],
},
},
{
spaces: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'],
base: [],
feature: {
feature2: ['read'],
feature3: ['all'],
},
},
{
spaces: ['k'],
base: ['all'],
feature: {
feature2: ['read'],
feature3: ['read'],
},
},
],
};
const calculator = new KibanaPrivilegeCalculatorFactory(
new KibanaPrivileges({
global: {
all: [],
read: [],
},
features: {
feature1: {
all: [],
read: [],
},
feature2: {
all: [],
read: [],
},
feature3: {
all: [],
read: [],
},
},
space: {
all: [],
read: [],
},
reserved: {},
})
).getInstance(role);
const wrapper = mountWithIntl(
<PrivilegeMatrix
role={role}
spaces={spaces}
features={features}
calculatedPrivileges={calculator.calculateEffectivePrivileges()}
intl={null as any}
/>
);
wrapper.find(EuiButtonEmpty).simulate('click');
wrapper.update();
const { columns, items } = wrapper.find(EuiInMemoryTable).props() as any;
expect(columns).toHaveLength(4); // all spaces groups plus the "feature" column
expect(items).toHaveLength(features.length + 1); // all features plus the "base" row
});
});

Some files were not shown because too many files have changed in this diff Show more