Move base feature controls functionality from XPack Main plugin to a dedicated XPack Features plugin (#44664)

This commit is contained in:
Aleh Zasypkin 2019-09-09 19:15:58 +02:00 committed by GitHub
parent b7aeaf5ad3
commit 9d69b72a5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1190 additions and 976 deletions

1
.github/CODEOWNERS vendored
View file

@ -38,6 +38,7 @@
/src/legacy/server/saved_objects/ @elastic/kibana-platform
/src/legacy/ui/public/saved_objects @elastic/kibana-platform
/config/kibana.yml @elastic/kibana-platform
/x-pack/plugins/features/ @elastic/kibana-platform
# Security
/x-pack/legacy/plugins/security/ @elastic/kibana-security

View file

@ -11,7 +11,7 @@ experimental[This API is *experimental* and may be changed or removed completely
[[features-api-get-request]]
==== Request
`GET /api/features/v1`
`GET /api/features`
[[features-api-get-codes]]
==== Response code

View file

@ -22,7 +22,7 @@ init(server) {
-----------
===== Feature details
Registering a feature consists of the following fields. For more information, consult the {repo}blob/{branch}/x-pack/legacy/plugins/xpack_main/server/lib/feature_registry/feature_registry.ts[feature registry interface].
Registering a feature consists of the following fields. For more information, consult the {repo}blob/{branch}/x-pack/plugins/features/server/feature_registry.ts[feature registry interface].
[cols="1a, 1a, 1a, 1a"]
@ -45,7 +45,7 @@ Registering a feature consists of the following fields. For more information, co
|An array of applications this feature enables. Typically, all of your plugin's apps (from `uiExports`) will be included here.
|`privileges` (required)
|{repo}blob/{branch}/x-pack/legacy/plugins/xpack_main/server/lib/feature_registry/feature_registry.ts[`FeatureWithAllOrReadPrivileges`].
|{repo}blob/{branch}/x-pack/plugins/features/server/feature.ts[`FeatureWithAllOrReadPrivileges`].
|see examples below
|The set of privileges this feature requires to function.
@ -63,7 +63,7 @@ Registering a feature consists of the following fields. For more information, co
===== Privilege definition
The `privileges` section of feature registration allows plugins to implement read/write and read-only modes for their applications.
For a full explanation of fields and options, consult the {repo}blob/{branch}/x-pack/legacy/plugins/xpack_main/server/lib/feature_registry/feature_registry.ts[feature registry interface].
For a full explanation of fields and options, consult the {repo}blob/{branch}/x-pack/plugins/features/server/feature_registry.ts[feature registry interface].
==== Using UI Capabilities
@ -142,7 +142,7 @@ init(server) {
const xpackMainPlugin = server.plugins.xpack_main;
xpackMainPlugin.registerFeature({
id: 'dev_tools',
name: i18n.translate('xpack.main.featureRegistry.devToolsFeatureName', {
name: i18n.translate('xpack.features.devToolsFeatureName', {
defaultMessage: 'Dev Tools',
}),
icon: 'devToolsApp',
@ -167,7 +167,7 @@ init(server) {
ui: ['show'],
},
},
privilegesTooltip: i18n.translate('xpack.main.featureRegistry.devToolsPrivilegesTooltip', {
privilegesTooltip: i18n.translate('xpack.features.devToolsPrivilegesTooltip', {
defaultMessage:
'User should also be granted the appropriate Elasticsearch cluster and index privileges',
}),

View file

@ -99,4 +99,5 @@ export const httpServiceMock = {
createSetupContract: createSetupContractMock,
createOnPreAuthToolkit: createOnPreAuthToolkitMock,
createAuthToolkit: createAuthToolkitMock,
createRouter: createRouterMock,
};

View file

@ -0,0 +1,8 @@
{
"id": "timelion",
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["timelion"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,28 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { schema } from '@kbn/config-schema';
export const ConfigSchema = schema.object(
{
ui: schema.object({ enabled: schema.boolean({ defaultValue: false }) }),
},
// This option should be removed as soon as we entirely migrate config from legacy Timelion plugin.
{ allowUnknowns: true }
);

View file

@ -0,0 +1,28 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginInitializerContext } from '../../../../src/core/server';
import { ConfigSchema } from './config';
import { Plugin } from './plugin';
export { PluginSetupContract } from './plugin';
export const config = { schema: ConfigSchema };
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);

View file

@ -0,0 +1,55 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { first } from 'rxjs/operators';
import { TypeOf } from '@kbn/config-schema';
import { PluginInitializerContext, RecursiveReadonly } from '../../../../src/core/server';
import { deepFreeze } from '../../../../src/core/utils';
import { ConfigSchema } from './config';
/**
* Describes public Timelion plugin contract returned at the `setup` stage.
*/
export interface PluginSetupContract {
uiEnabled: boolean;
}
/**
* Represents Timelion Plugin instance that will be managed by the Kibana plugin system.
*/
export class Plugin {
constructor(private readonly initializerContext: PluginInitializerContext) {}
public async setup(): Promise<RecursiveReadonly<PluginSetupContract>> {
const config = await this.initializerContext.config
.create<TypeOf<typeof ConfigSchema>>()
.pipe(first())
.toPromise();
return deepFreeze({ uiEnabled: config.ui.enabled });
}
public start() {
this.initializerContext.logger.get().debug('Starting plugin');
}
public stop() {
this.initializerContext.logger.get().debug('Stopping plugin');
}
}

View file

@ -10,6 +10,7 @@
"xpack.code": "legacy/plugins/code",
"xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication",
"xpack.dashboardMode": "legacy/plugins/dashboard_mode",
"xpack.features": "plugins/features",
"xpack.fileUpload": "legacy/plugins/file_upload",
"xpack.graph": "legacy/plugins/graph",
"xpack.grokDebugger": "legacy/plugins/grokdebugger",

View file

@ -9,7 +9,7 @@ import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { UICapabilities } from 'ui/capabilities';
import { Space } from '../../../../../../spaces/common/model/space';
import { Feature } from '../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../plugins/features/server';
import { RawKibanaPrivileges, Role } from '../../../../../common/model';
import { actionsFactory } from '../../../../../server/lib/authorization/actions';
import { privilegesFactory } from '../../../../../server/lib/authorization/privileges';

View file

@ -23,7 +23,7 @@ import React, { ChangeEvent, Component, Fragment, HTMLProps } from 'react';
import { UICapabilities } from 'ui/capabilities';
import { toastNotifications } from 'ui/notify';
import { Space } from '../../../../../../spaces/common/model/space';
import { Feature } from '../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../plugins/features/server';
import {
KibanaPrivileges,
RawKibanaPrivileges,

View file

@ -17,7 +17,7 @@ import {
import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import _ from 'lodash';
import React, { Component } from 'react';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../../../../../../common/model';
import {
AllowedPrivilege,

View file

@ -8,7 +8,7 @@ import { InjectedIntl } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { UICapabilities } from 'ui/capabilities';
import { Space } from '../../../../../../../../spaces/common/model/space';
import { Feature } from '../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../plugins/features/server';
import { KibanaPrivileges, Role } from '../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../../../../../../lib/kibana_privilege_calculator';
import { RoleValidator } from '../../../lib/validate_role';

View file

@ -7,7 +7,7 @@
import { EuiButtonGroup, EuiButtonGroupProps, EuiComboBox, EuiSuperSelect } from '@elastic/eui';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { KibanaPrivileges, Role } from '../../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../../../../../../../lib/kibana_privilege_calculator';
import { SimplePrivilegeSection } from './simple_privilege_section';

View file

@ -15,7 +15,7 @@ import {
import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import React, { Component, Fragment } from 'react';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../../../../../../../lib/kibana_privilege_calculator';
import { isGlobalPrivilegeDefinition } from '../../../../../../../lib/privilege_utils';

View file

@ -8,7 +8,7 @@ import { EuiButtonEmpty, EuiInMemoryTable } from '@elastic/eui';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { Space } from '../../../../../../../../../spaces/common/model/space';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { KibanaPrivileges, Role } from '../../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../../../../../../..//lib/kibana_privilege_calculator';
import { PrivilegeMatrix } from './privilege_matrix';

View file

@ -24,7 +24,7 @@ import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import React, { Component, Fragment } from 'react';
import { Space } from '../../../../../../../../../spaces/common/model/space';
import { SpaceAvatar } from '../../../../../../../../../spaces/public/components';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { FeaturesPrivileges, Role } from '../../../../../../../../common/model';
import { CalculatedPrivilege } from '../../../../../../../lib/kibana_privilege_calculator';
import { isGlobalPrivilegeDefinition } from '../../../../../../../lib/privilege_utils';

View file

@ -25,7 +25,7 @@ import {
import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import React, { Component, Fragment } from 'react';
import { Space } from '../../../../../../../../../spaces/common/model/space';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { KibanaPrivileges, Role } from '../../../../../../../../common/model';
import {
AllowedPrivilege,

View file

@ -16,7 +16,7 @@ import _ from 'lodash';
import React, { Component, Fragment } from 'react';
import { UICapabilities } from 'ui/capabilities';
import { Space } from '../../../../../../../../../spaces/common/model/space';
import { Feature } from '../../../../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../../../../plugins/features/server';
import { KibanaPrivileges, Role } from '../../../../../../../../common/model';
import { KibanaPrivilegeCalculatorFactory } from '../../../../../../../lib/kibana_privilege_calculator';
import { isReservedRole } from '../../../../../../../lib/role_utils';

View file

@ -92,7 +92,7 @@ const routeDefinition = (action) => ({
return kfetch({ method: 'get', pathname: '/api/security/v1/esPrivileges/builtin' });
},
features() {
return kfetch({ method: 'get', pathname: '/api/features/v1' }).catch(e => {
return kfetch({ method: 'get', pathname: '/api/features' }).catch(e => {
// TODO: This check can be removed once all of these `resolve` entries are moved out of Angular and into the React app.
const unauthorizedForFeatures = _.get(e, 'body.statusCode') === 404;
if (unauthorizedForFeatures) {

View file

@ -5,7 +5,7 @@
*/
import { isString } from 'lodash';
import { UICapabilities } from 'ui/capabilities';
import { uiCapabilitiesRegex } from '../../../../../xpack_main/types';
import { uiCapabilitiesRegex } from '../../../../../../../plugins/features/server';
export class UIActions {
private readonly prefix: string;

View file

@ -7,7 +7,7 @@
import { Server } from 'hapi';
import { AuthorizationService } from './service';
import { Feature } from '../../../../xpack_main/types';
import { Feature } from '../../../../../../plugins/features/server';
import { XPackMainPlugin } from '../../../../xpack_main/xpack_main';
import { actionsFactory } from './actions';
import { initAppAuthorization } from './app_authorization';

View file

@ -5,7 +5,7 @@
*/
import { Actions } from '.';
import { Feature } from '../../../../xpack_main/types';
import { Feature } from '../../../../../../plugins/features/server';
import { disableUICapabilitesFactory } from './disable_ui_capabilities';
interface MockServerOptions {

View file

@ -6,7 +6,7 @@
import { flatten, isObject, mapValues } from 'lodash';
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../../xpack_main/types';
import { Feature } from '../../../../../../plugins/features/server';
import { Actions } from './actions';
import { CheckPrivilegesAtResourceResponse } from './check_privileges';
import { CheckPrivilegesDynamically } from './check_privileges_dynamically';

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
export class FeaturePrivilegeApiBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature, FeatureKibanaPrivileges } from '../../../../../../xpack_main/types';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { Actions } from '../../actions';
export interface FeaturePrivilegeBuilder {

View file

@ -5,7 +5,7 @@
*/
import { flatten } from 'lodash';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../xpack_main/types';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { Actions } from '../../actions';
import { FeaturePrivilegeApiBuilder } from './api';
import { FeaturePrivilegeAppBuilder } from './app';

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
export class FeaturePrivilegeNavlinkBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -5,10 +5,7 @@
*/
import { flatten, uniq } from 'lodash';
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
const readOperations: string[] = ['bulk_get', 'get', 'find'];

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
Feature,
FeatureKibanaPrivileges,
} from '../../../../../../xpack_main/server/lib/feature_registry/feature_registry';
import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server';
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
export class FeaturePrivilegeUIBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature } from '../../../../../xpack_main/types';
import { Feature } from '../../../../../../../plugins/features/server';
import { Actions } from '../actions';
import { privilegesFactory } from './privileges';

View file

@ -5,7 +5,7 @@
*/
import { flatten, mapValues, uniq } from 'lodash';
import { Feature } from '../../../../../xpack_main/types';
import { Feature } from '../../../../../../../plugins/features/server';
import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main';
import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../../common/model';
import { Actions } from '../actions';

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature } from '../../../../xpack_main/types';
import { Feature } from '../../../../../../plugins/features/server';
import { actionsFactory } from './actions';
import { validateFeaturePrivileges } from './validate_feature_privileges';

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature } from '../../../../xpack_main/types';
import { Feature } from '../../../../../../plugins/features/server';
import { areActionsFullyCovered } from '../../../common/privilege_calculator_utils';
import { Actions } from './actions';
import { featurePrivilegeBuilderFactory } from './privileges/feature_privilege_builder';

View file

@ -7,7 +7,7 @@
import { EuiLink } from '@elastic/eui';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { Feature } from '../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../plugins/features/server';
import { Space } from '../../../../../common/model/space';
import { SectionPanel } from '../section_panel';
import { EnabledFeatures } from './enabled_features';

View file

@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from
import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import React, { Component, Fragment, ReactNode } from 'react';
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../plugins/features/server';
import { Space } from '../../../../../common/model/space';
import { getEnabledFeatures } from '../../lib/feature_utils';
import { SectionPanel } from '../section_panel';

View file

@ -8,7 +8,7 @@ import { EuiCheckbox, EuiIcon, EuiInMemoryTable, EuiSwitch, EuiText, IconType }
import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import _ from 'lodash';
import React, { ChangeEvent, Component } from 'react';
import { Feature } from '../../../../../../xpack_main/types';
import { Feature } from '../../../../../../../../plugins/features/server';
import { Space } from '../../../../../common/model/space';
import { ToggleAllFeatures } from './toggle_all_features';

View file

@ -22,7 +22,7 @@ import { capabilities } from 'ui/capabilities';
import { Breadcrumb } from 'ui/chrome';
import { kfetch } from 'ui/kfetch';
import { toastNotifications } from 'ui/notify';
import { Feature } from '../../../../../xpack_main/types';
import { Feature } from '../../../../../../../plugins/features/server';
import { isReservedSpace } from '../../../../common';
import { Space } from '../../../../common/model/space';
import { SpacesManager } from '../../../lib';
@ -79,7 +79,7 @@ class ManageSpacePageUI extends Component<Props, State> {
const { spaceId, spacesManager, intl, setBreadcrumbs } = this.props;
const getFeatures = kfetch({ method: 'get', pathname: '/api/features/v1' });
const getFeatures = kfetch({ method: 'get', pathname: '/api/features' });
if (spaceId) {
try {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature } from '../../../../../xpack_main/types';
import { Feature } from '../../../../../../../plugins/features/server';
import { getEnabledFeatures } from './feature_utils';
const buildFeatures = () =>

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature } from '../../../../../xpack_main/types';
import { Feature } from '../../../../../../../plugins/features/server';
import { Space } from '../../../../common/model/space';

View file

@ -23,7 +23,7 @@ import { capabilities } from 'ui/capabilities';
import { kfetch } from 'ui/kfetch';
// @ts-ignore
import { toastNotifications } from 'ui/notify';
import { Feature } from '../../../../../xpack_main/types';
import { Feature } from '../../../../../../../plugins/features/server';
import { isReservedSpace } from '../../../../common';
import { DEFAULT_SPACE_ID } from '../../../../common/constants';
import { Space } from '../../../../common/model/space';
@ -234,7 +234,7 @@ class SpacesGridPageUI extends Component<Props, State> {
});
const getSpaces = spacesManager.getSpaces();
const getFeatures = kfetch({ method: 'get', pathname: '/api/features/v1' });
const getFeatures = kfetch({ method: 'get', pathname: '/api/features' });
try {
const [spaces, features] = await Promise.all([getSpaces, getFeatures]);

View file

@ -6,7 +6,7 @@
import * as Rx from 'rxjs';
import { SavedObject, SavedObjectsService } from 'src/core/server';
import { Feature } from '../../../../xpack_main/types';
import { Feature } from '../../../../../../plugins/features/server';
import { convertSavedObjectToSpace } from '../../routes/lib';
import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor';
import { initSpacesOnRequestInterceptor } from './on_request_interceptor';

View file

@ -5,7 +5,7 @@
*/
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../xpack_main/types';
import { Feature } from '../../../../../plugins/features/server';
import { Space } from '../../common/model/space';
import { toggleUICapabilities } from './toggle_ui_capabilities';

View file

@ -5,7 +5,7 @@
*/
import _ from 'lodash';
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../xpack_main/types';
import { Feature } from '../../../../../plugins/features/server';
import { Space } from '../../common/model/space';
export function toggleUICapabilities(

View file

@ -13,14 +13,8 @@ import { getXpackConfigWithDeprecated } from '../telemetry/common/get_xpack_conf
import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status';
import { replaceInjectedVars } from './server/lib/replace_injected_vars';
import { setupXPackMain } from './server/lib/setup_xpack_main';
import {
xpackInfoRoute,
featuresRoute,
settingsRoute,
} from './server/routes/api/v1';
import { xpackInfoRoute, settingsRoute } from './server/routes/api/v1';
import { registerOssFeatures } from './server/lib/register_oss_features';
import { uiCapabilitiesForFeatures } from './server/lib/ui_capabilities_for_features';
import { has } from 'lodash';
function movedToTelemetry(configPath) {
@ -52,7 +46,11 @@ export const xpackMain = (kibana) => {
},
uiCapabilities(server) {
return uiCapabilitiesForFeatures(server.plugins.xpack_main);
const featuresPlugin = server.newPlatform.setup.plugins.features;
if (!featuresPlugin) {
throw new Error('New Platform XPack Features plugin is not available.');
}
return featuresPlugin.getFeaturesUICapabilities();
},
uiExports: {
@ -81,18 +79,21 @@ export const xpackMain = (kibana) => {
},
init(server) {
const featuresPlugin = server.newPlatform.setup.plugins.features;
if (!featuresPlugin) {
throw new Error('New Platform XPack Features plugin is not available.');
}
mirrorPluginStatus(server.plugins.elasticsearch, this, 'yellow', 'red');
setupXPackMain(server);
const { types: savedObjectTypes } = server.savedObjects;
const config = server.config();
const isTimelionUiEnabled = config.get('timelion.enabled') && config.get('timelion.ui.enabled');
registerOssFeatures(server.plugins.xpack_main.registerFeature, savedObjectTypes, isTimelionUiEnabled);
featuresPlugin.registerLegacyAPI({
xpackInfo: setupXPackMain(server),
savedObjectTypes: server.savedObjects.types
});
// register routes
xpackInfoRoute(server);
settingsRoute(server, this.kbnServer);
featuresRoute(server);
},
deprecations: () => [
movedToTelemetry('telemetry.config'),

View file

@ -39,6 +39,7 @@ describe('setupXPackMain()', () => {
elasticsearch: mockElasticsearchPlugin,
xpack_main: mockXPackMainPlugin
},
newPlatform: { setup: { plugins: { features: {} } } },
events: { on() {} },
log() {},
config() {},

View file

@ -1,401 +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 Joi from 'joi';
import { cloneDeep, difference, uniq } from 'lodash';
import { UICapabilities } from 'ui/capabilities';
/**
* Feature privilege definition
*/
export interface FeatureKibanaPrivileges {
/**
* Whether or not this specific privilege should be excluded from the base privileges.
*/
excludeFromBasePrivileges?: boolean;
/**
* If this feature includes management sections, you can specify them here to control visibility of those
* pages based on user privileges.
*
* Example:
* // Enables access to the "Advanced Settings" management page within the Kibana section
* management: {
* kibana: ['settings']
* }
*/
management?: {
[sectionId: string]: string[];
};
/**
* If this feature includes a catalogue entry, you can specify them here to control visibility based on user permissions.
*/
catalogue?: string[];
/**
* If your feature includes server-side APIs, you can tag those routes to secure access based on user permissions.
*
* Example:
* // Configure your routes with a tag starting with the 'access:' prefix
* server.route({
* path: '/api/my-route',
* method: 'GET',
* handler: () => { ...},
* options: {
* tags: ['access:my_feature-admin']
* }
* });
*
* Then, specify the tags here (without the 'access:' prefix) which should be secured:
*
* {
* api: ['my_feature-admin']
* }
*
* NOTE: It is important to name your tags in a way that will not collide with other plugins/features.
* A generic tag name like "access:read" could be used elsewhere, and access to that API endpoint would also
* extend to any routes you have also tagged with that name.
*/
api?: string[];
/**
* If your feature exposes a client-side application (most of them do!), then you can control access to them here.
*
* Example:
* {
* app: ['my-app', 'kibana']
* }
*
*/
app?: string[];
/**
* If your feature requires access to specific saved objects, then specify your access needs here.
*/
savedObject: {
/**
* List of saved object types which users should have full read/write access to when granted this privilege.
* Example:
* {
* all: ['my-saved-object-type']
* }
*/
all: string[];
/**
* List of saved object types which users should have read-only access to when granted this privilege.
* Example:
* {
* read: ['config']
* }
*/
read: string[];
};
/**
* A list of UI Capabilities that should be granted to users with this privilege.
* These capabilities will automatically be namespaces within your feature id.
*
* Example:
* {
* ui: ['show', 'save']
* }
*
* This translates in the UI to the following (assuming a feature id of "foo"):
* import { uiCapabilities } from 'ui/capabilities';
*
* const canShowApp = uiCapabilities.foo.show;
* const canSave = uiCapabilities.foo.save;
*
* Note: Since these are automatically namespaced, you are free to use generic names like "show" and "save".
*
* @see UICapabilities
*/
ui: string[];
}
type PrivilegesSet = Record<string, FeatureKibanaPrivileges>;
export type FeatureWithAllOrReadPrivileges = Feature<{
all?: FeatureKibanaPrivileges;
read?: FeatureKibanaPrivileges;
}>;
/**
* 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<PrivilegesSet> = PrivilegesSet> {
/**
* Unique identifier for this feature.
* This identifier is also used when generating UI Capabilities.
*
* @see UICapabilities
*/
id: string;
/**
* Display name for this feature.
* This will be displayed to end-users, so a translatable string is advised for i18n.
*/
name: string;
/**
* Whether or not this feature should be excluded from the base privileges.
* This is primarily helpful when migrating applications with a "legacy" privileges model
* to use Kibana privileges. We don't want these features to be considered part of the `all`
* or `read` base privileges in a minor release if the user was previously granted access
* using an additional reserved role.
*/
excludeFromBasePrivileges?: boolean;
/**
* Optional array of supported licenses.
* If omitted, all licenses are allowed.
* This does not restrict access to your feature based on license.
* Its only purpose is to inform the space and roles UIs on which features to display.
*/
validLicenses?: Array<'basic' | 'standard' | 'gold' | 'platinum'>;
/**
* An optional EUI Icon to be used when displaying your feature.
*/
icon?: string;
/**
* The optional Nav Link ID for feature.
* If specified, your link will be automatically hidden if needed based on the current space and user permissions.
*/
navLinkId?: string;
/**
* An array of app ids that are enabled when this feature is enabled.
* Apps specified here will automatically cascade to the privileges defined below, unless specified differently there.
*/
app: string[];
/**
* If this feature includes management sections, you can specify them here to control visibility of those
* pages based on the current space.
*
* Items specified here will automatically cascade to the privileges defined below, unless specified differently there.
*
* Example:
* // Enables access to the "Advanced Settings" management page within the Kibana section
* management: {
* kibana: ['settings']
* }
*/
management?: {
[sectionId: string]: string[];
};
/**
* If this feature includes a catalogue entry, you can specify them here to control visibility based on the current space.
*
* Items specified here will automatically cascade to the privileges defined below, unless specified differently there.
*/
catalogue?: string[];
/**
* Feature privilege definition.
*
* Example:
* {
* all: {...},
* read: {...}
* }
* @see FeatureKibanaPrivileges
*/
privileges: TPrivileges;
/**
* Optional message to display on the Role Management screen when configuring permissions for this feature.
*/
privilegesTooltip?: string;
/**
* @private
*/
reserved?: {
privilege: FeatureKibanaPrivileges;
description: string;
};
}
// 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 managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/;
export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/;
const managementSchema = Joi.object().pattern(
managementSectionIdRegex,
Joi.array().items(Joi.string().regex(uiCapabilitiesRegex))
);
const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex));
const privilegeSchema = Joi.object({
excludeFromBasePrivileges: Joi.boolean(),
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 schema = Joi.object({
id: Joi.string()
.regex(featurePrivilegePartRegex)
.invalid(...prohibitedFeatureIds)
.required(),
name: Joi.string().required(),
excludeFromBasePrivileges: Joi.boolean(),
validLicenses: Joi.array().items(Joi.string().valid('basic', 'standard', 'gold', 'platinum')),
icon: Joi.string(),
description: Joi.string(),
navLinkId: Joi.string().regex(uiCapabilitiesRegex),
app: Joi.array()
.items(Joi.string())
.required(),
management: managementSchema,
catalogue: catalogueSchema,
privileges: Joi.object({
all: privilegeSchema,
read: privilegeSchema,
}).required(),
privilegesTooltip: Joi.string(),
reserved: Joi.object({
privilege: privilegeSchema.required(),
description: Joi.string().required(),
}),
});
export class FeatureRegistry {
private locked = false;
private features: Record<string, Feature> = {};
public register(feature: FeatureWithAllOrReadPrivileges) {
if (this.locked) {
throw new Error(`Features are locked, can't register new features`);
}
validateFeature(feature);
if (feature.id in this.features) {
throw new Error(`Feature with id ${feature.id} is already registered.`);
}
const featureCopy: Feature = cloneDeep(feature as Feature);
this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy as Feature);
}
public getAll(): Feature[] {
this.locked = true;
return cloneDeep(Object.values(this.features));
}
}
function validateFeature(feature: FeatureWithAllOrReadPrivileges) {
const validateResult = Joi.validate(feature, schema);
if (validateResult.error) {
throw validateResult.error;
}
// 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]);
}
privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => {
if (!privilegeDefinition) {
throw new Error('Privilege definition may not be null or undefined');
}
const unknownAppEntries = difference(privilegeDefinition.app || [], app);
if (unknownAppEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}`
);
}
const unknownCatalogueEntries = difference(privilegeDefinition.catalogue || [], catalogue);
if (unknownCatalogueEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${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 applyAutomaticPrivilegeGrants(feature: Feature): Feature {
const { all: allPrivilege, read: readPrivilege } = feature.privileges;
const reservedPrivilege = feature.reserved ? feature.reserved.privilege : null;
applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege);
applyAutomaticReadPrivilegeGrants(readPrivilege);
return feature;
}
function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array<FeatureKibanaPrivileges | null>) {
allPrivileges.forEach(allPrivilege => {
if (allPrivilege) {
allPrivilege.savedObject.all = uniq([...allPrivilege.savedObject.all, 'telemetry']);
allPrivilege.savedObject.read = uniq([...allPrivilege.savedObject.read, 'config', 'url']);
}
});
}
function applyAutomaticReadPrivilegeGrants(
...readPrivileges: Array<FeatureKibanaPrivileges | null>
) {
readPrivileges.forEach(readPrivilege => {
if (readPrivilege) {
readPrivilege.savedObject.read = uniq([...readPrivilege.savedObject.read, 'config', 'url']);
}
});
}

View file

@ -1,49 +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 { FeatureRegistry } from './feature_registry';
import { registerOssFeatures } from './register_oss_features';
describe('registerOssFeatures', () => {
it('registers features including timelion', () => {
const registry = new FeatureRegistry();
const savedObjectTypes = ['foo', 'bar'];
registerOssFeatures(feature => registry.register(feature), savedObjectTypes, true);
const features = registry.getAll();
expect(features.map(f => f.id)).toMatchInlineSnapshot(`
Array [
"discover",
"visualize",
"dashboard",
"dev_tools",
"advancedSettings",
"indexPatterns",
"savedObjectsManagement",
"timelion",
]
`);
});
it('registers features excluding timelion', () => {
const registry = new FeatureRegistry();
const savedObjectTypes = ['foo', 'bar'];
registerOssFeatures(feature => registry.register(feature), savedObjectTypes, false);
const features = registry.getAll();
expect(features.map(f => f.id)).toMatchInlineSnapshot(`
Array [
"discover",
"visualize",
"dashboard",
"dev_tools",
"advancedSettings",
"indexPatterns",
"savedObjectsManagement",
]
`);
});
});

View file

@ -6,7 +6,6 @@
import { injectXPackInfoSignature } from './inject_xpack_info_signature';
import { XPackInfo } from './xpack_info';
import { FeatureRegistry } from './feature_registry';
/**
* Setup the X-Pack Main plugin. This is fired every time that the Elasticsearch plugin becomes Green.
@ -25,9 +24,9 @@ export function setupXPackMain(server) {
server.expose('createXPackInfo', (options) => new XPackInfo(server, options));
server.ext('onPreResponse', (request, h) => injectXPackInfoSignature(info, request, h));
const featureRegistry = new FeatureRegistry();
server.expose('registerFeature', (feature) => featureRegistry.register(feature));
server.expose('getFeatures', () => featureRegistry.getAll());
const { registerFeature, getFeatures } = server.newPlatform.setup.plugins.features;
server.expose('registerFeature', registerFeature);
server.expose('getFeatures', getFeatures);
const setPluginStatus = () => {
if (info.isAvailable()) {
@ -46,4 +45,6 @@ export function setupXPackMain(server) {
// whenever the license info is updated, regardless of the elasticsearch plugin status
// changes, reflect the change in our plugin status. See https://github.com/elastic/kibana/issues/20017
info.onLicenseInfoChange(setPluginStatus);
return info;
}

View file

@ -1,196 +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 { Feature } from './feature_registry';
import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features';
function getMockXpackMainPlugin(features: Feature[]) {
return {
getFeatures: () => features,
};
}
function createFeaturePrivilege(key: string, capabilities: string[] = []) {
return {
[key]: {
savedObject: {
all: [],
read: [],
},
app: [],
ui: [...capabilities],
},
};
}
describe('populateUICapabilities', () => {
it('handles no original uiCapabilites and no registered features gracefully', () => {
const xpackMainPlugin = getMockXpackMainPlugin([]);
expect(uiCapabilitiesForFeatures(xpackMainPlugin)).toEqual({});
});
it('handles features with no registered capabilities', () => {
const xpackMainPlugin = getMockXpackMainPlugin([
{
id: 'newFeature',
name: 'my new feature',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all'),
},
},
]);
expect(uiCapabilitiesForFeatures(xpackMainPlugin)).toEqual({
catalogue: {},
newFeature: {},
});
});
it('augments the original uiCapabilities with registered feature capabilities', () => {
const xpackMainPlugin = getMockXpackMainPlugin([
{
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all', ['capability1', 'capability2']),
},
},
]);
expect(uiCapabilitiesForFeatures(xpackMainPlugin)).toEqual({
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
},
});
});
it('combines catalogue entries from multiple features', () => {
const xpackMainPlugin = getMockXpackMainPlugin([
{
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'),
},
},
]);
expect(uiCapabilitiesForFeatures(xpackMainPlugin)).toEqual({
catalogue: {
anotherFooEntry: true,
anotherBarEntry: true,
},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
},
});
});
it(`merges capabilities from all feature privileges`, () => {
const xpackMainPlugin = getMockXpackMainPlugin([
{
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz', ['capability1', 'capability5']),
},
},
]);
expect(uiCapabilitiesForFeatures(xpackMainPlugin)).toEqual({
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
capability5: true,
},
});
});
it('supports merging multiple features with multiple privileges each', () => {
const xpackMainPlugin = getMockXpackMainPlugin([
{
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz', ['capability1', 'capability5']),
},
},
{
id: 'anotherNewFeature',
name: 'another new feature',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
},
},
{
id: 'yetAnotherNewFeature',
name: 'yet another new feature',
navLinkId: 'yetAnotherNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all', ['capability1', 'capability2']),
...createFeaturePrivilege('read', []),
...createFeaturePrivilege('somethingInBetween', [
'something1',
'something2',
'something3',
]),
},
},
]);
expect(uiCapabilitiesForFeatures(xpackMainPlugin)).toEqual({
anotherNewFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
},
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
capability5: true,
},
yetAnotherNewFeature: {
capability1: true,
capability2: true,
something1: true,
something2: true,
something3: true,
},
});
});
});

View file

@ -1,34 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GET /api/features/v1 does not return features that arent allowed by current license 1`] = `
Array [
Object {
"app": Array [],
"id": "feature_1",
"name": "Feature 1",
"privileges": Object {},
},
]
`;
exports[`GET /api/features/v1 returns a list of available features 1`] = `
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",
],
},
]
`;

View file

@ -1,80 +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 { Server } from 'hapi';
import { KibanaConfig } from 'src/legacy/server/kbn_server';
import { FeatureRegistry } from '../../../../lib/feature_registry';
// @ts-ignore
import { setupXPackMain } from '../../../../lib/setup_xpack_main';
import { featuresRoute } from './features';
let server: Server;
let currentLicenseLevel: string = 'gold';
describe('GET /api/features/v1', () => {
beforeAll(() => {
server = new Server();
const config: Record<string, any> = {};
server.config = () => {
return {
get: (key: string) => {
return config[key];
},
} as KibanaConfig;
};
const featureRegistry = new FeatureRegistry();
// @ts-ignore
server.plugins.xpack_main = {
getFeatures: () => featureRegistry.getAll(),
info: {
// @ts-ignore
license: {
isOneOf: (candidateLicenses: string[]) => {
return candidateLicenses.includes(currentLicenseLevel);
},
},
},
};
featuresRoute(server);
featureRegistry.register({
id: 'feature_1',
name: 'Feature 1',
app: [],
privileges: {},
});
featureRegistry.register({
id: 'licensed_feature',
name: 'Licensed Feature',
app: ['bar-app'],
validLicenses: ['gold'],
privileges: {},
});
});
it('returns a list of available features', async () => {
const response = await server.inject({
url: '/api/features/v1',
});
expect(response.statusCode).toEqual(200);
expect(JSON.parse(response.payload)).toMatchSnapshot();
});
it(`does not return features that arent allowed by current license`, async () => {
currentLicenseLevel = 'basic';
const response = await server.inject({
url: '/api/features/v1',
});
expect(response.statusCode).toEqual(200);
expect(JSON.parse(response.payload)).toMatchSnapshot();
});
});

View file

@ -1,29 +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 { Feature } from '../../../../../types';
export function featuresRoute(server: Record<string, any>) {
server.route({
path: '/api/features/v1',
method: 'GET',
options: {
tags: ['access:features'],
},
async handler(request: Record<string, any>) {
const xpackInfo = server.plugins.xpack_main.info;
const allFeatures: Feature[] = server.plugins.xpack_main.getFeatures();
return allFeatures.filter(
feature =>
!feature.validLicenses ||
!feature.validLicenses.length ||
xpackInfo.license.isOneOf(feature.validLicenses)
);
},
});
}

View file

@ -1,7 +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 { featuresRoute } from './features';

View file

@ -5,5 +5,4 @@
*/
export { xpackInfoRoute } from './xpack_info';
export { featuresRoute } from './features';
export { settingsRoute } from './settings';

View file

@ -1,11 +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 {
Feature,
FeatureKibanaPrivileges,
uiCapabilitiesRegex,
} from './server/lib/feature_registry';

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature, FeatureWithAllOrReadPrivileges } from './server/lib/feature_registry';
import { Feature, FeatureWithAllOrReadPrivileges } from '../../../plugins/features/server';
import { XPackInfo, XPackInfoOptions } from './server/lib/xpack_info';
export { XPackFeature } from './server/lib/xpack_info';

View file

@ -0,0 +1,8 @@
{
"id": "features",
"version": "8.0.0",
"kibanaVersion": "kibana",
"optionalPlugins": ["timelion"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,120 @@
/*
* 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 { FeatureKibanaPrivileges, FeatureKibanaPrivilegesSet } from './feature_kibana_privileges';
/**
* 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
> {
/**
* Unique identifier for this feature.
* This identifier is also used when generating UI Capabilities.
*
* @see UICapabilities
*/
id: string;
/**
* Display name for this feature.
* This will be displayed to end-users, so a translatable string is advised for i18n.
*/
name: string;
/**
* Whether or not this feature should be excluded from the base privileges.
* This is primarily helpful when migrating applications with a "legacy" privileges model
* to use Kibana privileges. We don't want these features to be considered part of the `all`
* or `read` base privileges in a minor release if the user was previously granted access
* using an additional reserved role.
*/
excludeFromBasePrivileges?: boolean;
/**
* Optional array of supported licenses.
* If omitted, all licenses are allowed.
* This does not restrict access to your feature based on license.
* Its only purpose is to inform the space and roles UIs on which features to display.
*/
validLicenses?: Array<'basic' | 'standard' | 'gold' | 'platinum'>;
/**
* An optional EUI Icon to be used when displaying your feature.
*/
icon?: string;
/**
* The optional Nav Link ID for feature.
* If specified, your link will be automatically hidden if needed based on the current space and user permissions.
*/
navLinkId?: string;
/**
* An array of app ids that are enabled when this feature is enabled.
* Apps specified here will automatically cascade to the privileges defined below, unless specified differently there.
*/
app: string[];
/**
* If this feature includes management sections, you can specify them here to control visibility of those
* pages based on the current space.
*
* Items specified here will automatically cascade to the privileges defined below, unless specified differently there.
*
* @example
* ```ts
* // Enables access to the "Advanced Settings" management page within the Kibana section
* management: {
* kibana: ['settings']
* }
* ```
*/
management?: {
[sectionId: string]: string[];
};
/**
* If this feature includes a catalogue entry, you can specify them here to control visibility based on the current space.
*
* Items specified here will automatically cascade to the privileges defined below, unless specified differently there.
*/
catalogue?: string[];
/**
* Feature privilege definition.
*
* @example
* ```ts
* {
* all: {...},
* read: {...}
* }
* ```
* @see FeatureKibanaPrivileges
*/
privileges: TPrivileges;
/**
* Optional message to display on the Role Management screen when configuring permissions for this feature.
*/
privilegesTooltip?: string;
/**
* @private
*/
reserved?: {
privilege: FeatureKibanaPrivileges;
description: string;
};
}
export type FeatureWithAllOrReadPrivileges = Feature<{
all?: FeatureKibanaPrivileges;
read?: FeatureKibanaPrivileges;
}>;

View file

@ -0,0 +1,127 @@
/*
* 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.
*/
/**
* Feature privilege definition
*/
export interface FeatureKibanaPrivileges {
/**
* Whether or not this specific privilege should be excluded from the base privileges.
*/
excludeFromBasePrivileges?: boolean;
/**
* If this feature includes management sections, you can specify them here to control visibility of those
* pages based on user privileges.
*
* @example
* ```ts
* // Enables access to the "Advanced Settings" management page within the Kibana section
* management: {
* kibana: ['settings']
* }
* ```
*/
management?: {
[sectionId: string]: string[];
};
/**
* If this feature includes a catalogue entry, you can specify them here to control visibility based on user permissions.
*/
catalogue?: string[];
/**
* If your feature includes server-side APIs, you can tag those routes to secure access based on user permissions.
*
* @example
* ```ts
* // Configure your routes with a tag starting with the 'access:' prefix
* server.route({
* path: '/api/my-route',
* method: 'GET',
* handler: () => { ...},
* options: {
* tags: ['access:my_feature-admin']
* }
* });
*
* Then, specify the tags here (without the 'access:' prefix) which should be secured:
*
* {
* api: ['my_feature-admin']
* }
* ```
*
* NOTE: It is important to name your tags in a way that will not collide with other plugins/features.
* A generic tag name like "access:read" could be used elsewhere, and access to that API endpoint would also
* extend to any routes you have also tagged with that name.
*/
api?: string[];
/**
* If your feature exposes a client-side application (most of them do!), then you can control access to them here.
*
* @example
* ```ts
* {
* app: ['my-app', 'kibana']
* }
* ```
*
*/
app?: string[];
/**
* If your feature requires access to specific saved objects, then specify your access needs here.
*/
savedObject: {
/**
* List of saved object types which users should have full read/write access to when granted this privilege.
* @example
* ```ts
* {
* all: ['my-saved-object-type']
* }
* ```
*/
all: string[];
/**
* List of saved object types which users should have read-only access to when granted this privilege.
* @example
* ```ts
* {
* read: ['config']
* }
* ```
*/
read: string[];
};
/**
* A list of UI Capabilities that should be granted to users with this privilege.
* These capabilities will automatically be namespaces within your feature id.
*
* @example
* ```ts
* {
* ui: ['show', 'save']
* }
*
* This translates in the UI to the following (assuming a feature id of "foo"):
* import { uiCapabilities } from 'ui/capabilities';
*
* const canShowApp = uiCapabilities.foo.show;
* const canSave = uiCapabilities.foo.save;
* ```
* Note: Since these are automatically namespaced, you are free to use generic names like "show" and "save".
*
* @see UICapabilities
*/
ui: string[];
}
export type FeatureKibanaPrivilegesSet = Record<string, FeatureKibanaPrivileges>;

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Feature, FeatureRegistry } from './feature_registry';
import { FeatureRegistry } from './feature_registry';
import { Feature } from './feature';
describe('FeatureRegistry', () => {
it('allows a minimal feature to be registered', () => {

View file

@ -0,0 +1,65 @@
/*
* 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 { cloneDeep, uniq } from 'lodash';
import { FeatureKibanaPrivileges } from './feature_kibana_privileges';
import { Feature, FeatureWithAllOrReadPrivileges } from './feature';
import { validateFeature } from './feature_schema';
export class FeatureRegistry {
private locked = false;
private features: Record<string, Feature> = {};
public register(feature: FeatureWithAllOrReadPrivileges) {
if (this.locked) {
throw new Error(`Features are locked, can't register new features`);
}
validateFeature(feature);
if (feature.id in this.features) {
throw new Error(`Feature with id ${feature.id} is already registered.`);
}
const featureCopy: Feature = cloneDeep(feature as Feature);
this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy as Feature);
}
public getAll(): Feature[] {
this.locked = true;
return cloneDeep(Object.values(this.features));
}
}
function applyAutomaticPrivilegeGrants(feature: Feature): Feature {
const { all: allPrivilege, read: readPrivilege } = feature.privileges;
const reservedPrivilege = feature.reserved ? feature.reserved.privilege : null;
applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege);
applyAutomaticReadPrivilegeGrants(readPrivilege);
return feature;
}
function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array<FeatureKibanaPrivileges | null>) {
allPrivileges.forEach(allPrivilege => {
if (allPrivilege) {
allPrivilege.savedObject.all = uniq([...allPrivilege.savedObject.all, 'telemetry']);
allPrivilege.savedObject.read = uniq([...allPrivilege.savedObject.read, 'config', 'url']);
}
});
}
function applyAutomaticReadPrivilegeGrants(
...readPrivileges: Array<FeatureKibanaPrivileges | null>
) {
readPrivileges.forEach(readPrivilege => {
if (readPrivilege) {
readPrivilege.savedObject.read = uniq([...readPrivilege.savedObject.read, 'config', 'url']);
}
});
}

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 Joi from 'joi';
import { difference } from 'lodash';
import { Capabilities as UICapabilities } from '../../../../src/core/public';
import { FeatureWithAllOrReadPrivileges } from './feature';
// 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 managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/;
export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/;
const managementSchema = Joi.object().pattern(
managementSectionIdRegex,
Joi.array().items(Joi.string().regex(uiCapabilitiesRegex))
);
const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex));
const privilegeSchema = Joi.object({
excludeFromBasePrivileges: Joi.boolean(),
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 schema = Joi.object({
id: Joi.string()
.regex(featurePrivilegePartRegex)
.invalid(...prohibitedFeatureIds)
.required(),
name: Joi.string().required(),
excludeFromBasePrivileges: Joi.boolean(),
validLicenses: Joi.array().items(Joi.string().valid('basic', 'standard', 'gold', 'platinum')),
icon: Joi.string(),
description: Joi.string(),
navLinkId: Joi.string().regex(uiCapabilitiesRegex),
app: Joi.array()
.items(Joi.string())
.required(),
management: managementSchema,
catalogue: catalogueSchema,
privileges: Joi.object({
all: privilegeSchema,
read: privilegeSchema,
}).required(),
privilegesTooltip: Joi.string(),
reserved: Joi.object({
privilege: privilegeSchema.required(),
description: Joi.string().required(),
}),
});
export function validateFeature(feature: FeatureWithAllOrReadPrivileges) {
const validateResult = Joi.validate(feature, schema);
if (validateResult.error) {
throw validateResult.error;
}
// 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]);
}
privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => {
if (!privilegeDefinition) {
throw new Error('Privilege definition may not be null or undefined');
}
const unknownAppEntries = difference(privilegeDefinition.app || [], app);
if (unknownAppEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}`
);
}
const unknownCatalogueEntries = difference(privilegeDefinition.catalogue || [], catalogue);
if (unknownCatalogueEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${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(
', '
)}`
);
}
}
);
});
}

View file

@ -0,0 +1,21 @@
/*
* 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 { PluginInitializerContext } from '../../../../src/core/server';
import { Plugin } from './plugin';
// These exports are part of public Features plugin contract, any change in signature of exported
// functions or removal of exports should be considered as a breaking change. Ideally we should
// reduce number of such exports to zero and provide everything we want to expose via Setup/Start
// run-time contracts.
export { uiCapabilitiesRegex } from './feature_schema';
export { Feature, FeatureWithAllOrReadPrivileges } from './feature';
export { FeatureKibanaPrivileges } from './feature_kibana_privileges';
export { PluginSetupContract } from './plugin';
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);

View file

@ -0,0 +1,42 @@
/*
* 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 { buildOSSFeatures } from './oss_features';
describe('buildOSSFeatures', () => {
it('returns features including timelion', () => {
expect(
buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }).map(f => f.id)
).toMatchInlineSnapshot(`
Array [
"discover",
"visualize",
"dashboard",
"dev_tools",
"advancedSettings",
"indexPatterns",
"savedObjectsManagement",
"timelion",
]
`);
});
it('returns features excluding timelion', () => {
expect(
buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: false }).map(f => f.id)
).toMatchInlineSnapshot(`
Array [
"discover",
"visualize",
"dashboard",
"dev_tools",
"advancedSettings",
"indexPatterns",
"savedObjectsManagement",
]
`);
});
});

View file

@ -4,13 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { Feature } from './feature_registry';
import { Feature } from './feature';
const buildKibanaFeatures = (savedObjectTypes: string[]) => {
export interface BuildOSSFeaturesParams {
savedObjectTypes: string[];
includeTimelion: boolean;
}
export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSSFeaturesParams) => {
return [
{
id: 'discover',
name: i18n.translate('xpack.main.featureRegistry.discoverFeatureName', {
name: i18n.translate('xpack.features.discoverFeatureName', {
defaultMessage: 'Discover',
}),
icon: 'discoverApp',
@ -36,7 +41,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
},
{
id: 'visualize',
name: i18n.translate('xpack.main.featureRegistry.visualizeFeatureName', {
name: i18n.translate('xpack.features.visualizeFeatureName', {
defaultMessage: 'Visualize',
}),
icon: 'visualizeApp',
@ -62,7 +67,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
},
{
id: 'dashboard',
name: i18n.translate('xpack.main.featureRegistry.dashboardFeatureName', {
name: i18n.translate('xpack.features.dashboardFeatureName', {
defaultMessage: 'Dashboard',
}),
icon: 'dashboardApp',
@ -104,7 +109,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
},
{
id: 'dev_tools',
name: i18n.translate('xpack.main.featureRegistry.devToolsFeatureName', {
name: i18n.translate('xpack.features.devToolsFeatureName', {
defaultMessage: 'Dev Tools',
}),
icon: 'devToolsApp',
@ -129,14 +134,14 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
ui: ['show'],
},
},
privilegesTooltip: i18n.translate('xpack.main.featureRegistry.devToolsPrivilegesTooltip', {
privilegesTooltip: i18n.translate('xpack.features.devToolsPrivilegesTooltip', {
defaultMessage:
'User should also be granted the appropriate Elasticsearch cluster and index privileges',
}),
},
{
id: 'advancedSettings',
name: i18n.translate('xpack.main.featureRegistry.advancedSettingsFeatureName', {
name: i18n.translate('xpack.features.advancedSettingsFeatureName', {
defaultMessage: 'Advanced Settings',
}),
icon: 'advancedSettingsApp',
@ -164,7 +169,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
},
{
id: 'indexPatterns',
name: i18n.translate('xpack.main.featureRegistry.indexPatternFeatureName', {
name: i18n.translate('xpack.features.indexPatternFeatureName', {
defaultMessage: 'Index Pattern Management',
}),
icon: 'indexPatternApp',
@ -192,7 +197,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
},
{
id: 'savedObjectsManagement',
name: i18n.translate('xpack.main.featureRegistry.savedObjectsManagementFeatureName', {
name: i18n.translate('xpack.features.savedObjectsManagementFeatureName', {
defaultMessage: 'Saved Objects Management',
}),
icon: 'savedObjectsApp',
@ -220,6 +225,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => {
},
},
},
...(includeTimelion ? [timelionFeature] : []),
];
};
@ -247,17 +253,3 @@ const timelionFeature: Feature = {
},
},
};
export function registerOssFeatures(
registerFeature: (feature: Feature) => void,
savedObjectTypes: string[],
includeTimelion: boolean
) {
for (const feature of buildKibanaFeatures(savedObjectTypes)) {
registerFeature(feature);
}
if (includeTimelion) {
registerFeature(timelionFeature);
}
}

View file

@ -0,0 +1,98 @@
/*
* 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 {
CoreSetup,
Logger,
PluginInitializerContext,
RecursiveReadonly,
} from '../../../../src/core/server';
import { Capabilities as UICapabilities } from '../../../../src/core/public';
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 './feature';
import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features';
import { buildOSSFeatures } from './oss_features';
import { defineRoutes } from './routes';
/**
* Describes public Features plugin contract returned at the `setup` stage.
*/
export interface PluginSetupContract {
registerFeature(feature: FeatureWithAllOrReadPrivileges): void;
getFeatures(): Feature[];
getFeaturesUICapabilities(): UICapabilities;
registerLegacyAPI: (legacyAPI: LegacyAPI) => void;
}
/**
* Describes a set of APIs that are available in the legacy platform only and required by this plugin
* to function properly.
*/
export interface LegacyAPI {
xpackInfo: Pick<XPackInfo, 'license'>;
savedObjectTypes: string[];
}
/**
* Represents Features Plugin instance that will be managed by the Kibana plugin system.
*/
export class Plugin {
private readonly logger: Logger;
private legacyAPI?: LegacyAPI;
private readonly getLegacyAPI = () => {
if (!this.legacyAPI) {
throw new Error('Legacy API is not registered!');
}
return this.legacyAPI;
};
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = this.initializerContext.logger.get();
}
public async setup(
core: CoreSetup,
{ timelion }: { timelion?: TimelionSetupContract }
): Promise<RecursiveReadonly<PluginSetupContract>> {
const featureRegistry = new FeatureRegistry();
defineRoutes({
router: core.http.createRouter(),
featureRegistry,
getLegacyAPI: this.getLegacyAPI,
});
return deepFreeze({
registerFeature: featureRegistry.register.bind(featureRegistry),
getFeatures: featureRegistry.getAll.bind(featureRegistry),
getFeaturesUICapabilities: () => uiCapabilitiesForFeatures(featureRegistry.getAll()),
registerLegacyAPI: (legacyAPI: LegacyAPI) => {
this.legacyAPI = legacyAPI;
// Register OSS features.
for (const feature of buildOSSFeatures({
savedObjectTypes: this.legacyAPI.savedObjectTypes,
includeTimelion: timelion !== undefined && timelion.uiEnabled,
})) {
featureRegistry.register(feature);
}
},
});
}
public start() {
this.logger.debug('Starting plugin');
}
public stop() {
this.logger.debug('Stopping plugin');
}
}

View file

@ -0,0 +1,110 @@
/*
* 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 { FeatureRegistry } from '../feature_registry';
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';
let currentLicenseLevel: string = 'gold';
describe('GET /api/features', () => {
let routeHandler: RequestHandler<any, any, any>;
beforeEach(() => {
const featureRegistry = new FeatureRegistry();
featureRegistry.register({
id: 'feature_1',
name: 'Feature 1',
app: [],
privileges: {},
});
featureRegistry.register({
id: 'licensed_feature',
name: 'Licensed Feature',
app: ['bar-app'],
validLicenses: ['gold'],
privileges: {},
});
const routerMock = httpServiceMock.createRouter();
defineRoutes({
router: routerMock,
featureRegistry,
getLegacyAPI: () => ({
xpackInfo: {
license: {
isOneOf(candidateLicenses: string[]) {
return candidateLicenses.includes(currentLicenseLevel);
},
} as XPackInfoLicense,
},
savedObjectTypes: [],
}),
});
routeHandler = routerMock.get.mock.calls[0][1];
});
it('returns a list of available features', async () => {
const mockResponse = httpServerMock.createResponseFactory();
routeHandler(undefined as any, undefined 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",
],
},
],
},
],
]
`);
});
it(`does not return features that arent allowed by current license`, async () => {
currentLicenseLevel = 'basic';
const mockResponse = httpServerMock.createResponseFactory();
routeHandler(undefined as any, undefined 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 {},
},
],
},
],
]
`);
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 { IRouter } from '../../../../../src/core/server';
import { LegacyAPI } from '../plugin';
import { FeatureRegistry } from '../feature_registry';
/**
* Describes parameters used to define HTTP routes.
*/
export interface RouteDefinitionParams {
router: IRouter;
featureRegistry: FeatureRegistry;
getLegacyAPI: () => LegacyAPI;
}
export function defineRoutes({ router, featureRegistry, getLegacyAPI }: RouteDefinitionParams) {
router.get(
{ path: '/api/features', options: { tags: ['access:features'] }, validate: false },
(context, request, response) => {
const allFeatures = featureRegistry.getAll();
return response.ok({
body: allFeatures.filter(
feature =>
!feature.validLicenses ||
!feature.validLicenses.length ||
getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses)
),
});
}
);
}

View file

@ -0,0 +1,187 @@
/*
* 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 { uiCapabilitiesForFeatures } from './ui_capabilities_for_features';
function createFeaturePrivilege(key: string, capabilities: string[] = []) {
return {
[key]: {
savedObject: {
all: [],
read: [],
},
app: [],
ui: [...capabilities],
},
};
}
describe('populateUICapabilities', () => {
it('handles no original uiCapabilities and no registered features gracefully', () => {
expect(uiCapabilitiesForFeatures([])).toEqual({});
});
it('handles features with no registered capabilities', () => {
expect(
uiCapabilitiesForFeatures([
{
id: 'newFeature',
name: 'my new feature',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all'),
},
},
])
).toEqual({
catalogue: {},
newFeature: {},
});
});
it('augments the original uiCapabilities with registered feature capabilities', () => {
expect(
uiCapabilitiesForFeatures([
{
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all', ['capability1', 'capability2']),
},
},
])
).toEqual({
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
},
});
});
it('combines catalogue entries from multiple features', () => {
expect(
uiCapabilitiesForFeatures([
{
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'),
},
},
])
).toEqual({
catalogue: {
anotherFooEntry: true,
anotherBarEntry: true,
},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
},
});
});
it(`merges capabilities from all feature privileges`, () => {
expect(
uiCapabilitiesForFeatures([
{
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz', ['capability1', 'capability5']),
},
},
])
).toEqual({
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
capability5: true,
},
});
});
it('supports merging multiple features with multiple privileges each', () => {
expect(
uiCapabilitiesForFeatures([
{
id: 'newFeature',
name: 'my new feature',
navLinkId: 'newFeatureNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
...createFeaturePrivilege('baz', ['capability1', 'capability5']),
},
},
{
id: 'anotherNewFeature',
name: 'another new feature',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('foo', ['capability1', 'capability2']),
...createFeaturePrivilege('bar', ['capability3', 'capability4']),
},
},
{
id: 'yetAnotherNewFeature',
name: 'yet another new feature',
navLinkId: 'yetAnotherNavLink',
app: ['bar-app'],
privileges: {
...createFeaturePrivilege('all', ['capability1', 'capability2']),
...createFeaturePrivilege('read', []),
...createFeaturePrivilege('somethingInBetween', [
'something1',
'something2',
'something3',
]),
},
},
])
).toEqual({
anotherNewFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
},
catalogue: {},
newFeature: {
capability1: true,
capability2: true,
capability3: true,
capability4: true,
capability5: true,
},
yetAnotherNewFeature: {
capability1: true,
capability2: true,
something1: true,
something2: true,
something3: true,
},
});
});
});

View file

@ -5,8 +5,8 @@
*/
import _ from 'lodash';
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../types';
import { Capabilities as UICapabilities } from '../../../../src/core/public';
import { Feature } from './feature';
const ELIGIBLE_FLAT_MERGE_KEYS = ['catalogue'];
@ -14,9 +14,7 @@ interface FeatureCapabilities {
[featureId: string]: Record<string, boolean>;
}
export function uiCapabilitiesForFeatures(xpackMainPlugin: Record<string, any>): UICapabilities {
const features: Feature[] = xpackMainPlugin.getFeatures();
export function uiCapabilitiesForFeatures(features: Feature[]): UICapabilities {
const featureCapabilities: FeatureCapabilities[] = features.map(getCapabilitiesFromFeature);
return buildCapabilities(...featureCapabilities);

View file

@ -4501,6 +4501,14 @@
"xpack.dashboardMode.dashboardViewerTitle": "ダッシュボードビューアー",
"xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDescription": "ダッシュボード表示専用モードのロールです",
"xpack.dashboardMode.uiSettings.dashboardsOnlyRolesTitle": "ダッシュボード専用ロール",
"xpack.features.advancedSettingsFeatureName": "高度な設定",
"xpack.features.dashboardFeatureName": "ダッシュボード",
"xpack.features.devToolsFeatureName": "開発ツール",
"xpack.features.devToolsPrivilegesTooltip": "また、ユーザーに適切な Elasticsearch クラスターとインデックスの権限が与えられている必要があります。",
"xpack.features.discoverFeatureName": "ディスカバー",
"xpack.features.indexPatternFeatureName": "インデックスパターン管理",
"xpack.features.savedObjectsManagementFeatureName": "保存されたオブジェクトの管理",
"xpack.features.visualizeFeatureName": "可視化",
"xpack.graph.badge.readOnly.text": "読み込み専用",
"xpack.graph.badge.readOnly.tooltip": "Graph ワークスペースを保存できません",
"xpack.graph.clearWorkspace.confirmButtonLabel": "ワークスペースを消去",
@ -5371,14 +5379,6 @@
"xpack.logstash.upgradeFailureActions.goBackButtonLabel": "戻る",
"xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage": "upstreamPipeline 引数には id プロパティを含める必要があります",
"xpack.logstash.workersTooltip": "パイプラインのフィルターとアウトプットステージを同時に実行するワーカーの数です。イベントが詰まってしまう場合や CPU が飽和状態ではない場合は、マシンの処理能力をより有効に活用するため、この数字を上げてみてください。\n\nデフォルト値:ホストの CPU コア数です",
"xpack.main.featureRegistry.advancedSettingsFeatureName": "高度な設定",
"xpack.main.featureRegistry.dashboardFeatureName": "ダッシュボード",
"xpack.main.featureRegistry.devToolsFeatureName": "開発ツール",
"xpack.main.featureRegistry.devToolsPrivilegesTooltip": "また、ユーザーに適切な Elasticsearch クラスターとインデックスの権限が与えられている必要があります。",
"xpack.main.featureRegistry.discoverFeatureName": "ディスカバー",
"xpack.main.featureRegistry.indexPatternFeatureName": "インデックスパターン管理",
"xpack.main.featureRegistry.savedObjectsManagementFeatureName": "保存されたオブジェクトの管理",
"xpack.main.featureRegistry.visualizeFeatureName": "可視化",
"xpack.main.welcomeBanner.licenseIsExpiredDescription": "管理者または {updateYourLicenseLinkText} に直接お問い合わせください。",
"xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新",
"xpack.main.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです",

View file

@ -4644,6 +4644,14 @@
"xpack.dashboardMode.dashboardViewerTitle": "仪表板查看器",
"xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDescription": "属于“仅查看仪表板”模式的角色",
"xpack.dashboardMode.uiSettings.dashboardsOnlyRolesTitle": "仅限仪表板的角色",
"xpack.features.advancedSettingsFeatureName": "高级设置",
"xpack.features.dashboardFeatureName": "仪表板",
"xpack.features.devToolsFeatureName": "开发工具",
"xpack.features.devToolsPrivilegesTooltip": "还应向用户授予适当的 Elasticsearch 集群和索引权限",
"xpack.features.discoverFeatureName": "Discover",
"xpack.features.indexPatternFeatureName": "索引模式管理",
"xpack.features.savedObjectsManagementFeatureName": "已保存对象管理",
"xpack.features.visualizeFeatureName": "可视化",
"xpack.graph.badge.readOnly.text": "只读",
"xpack.graph.badge.readOnly.tooltip": "无法保存 Graph 工作空间",
"xpack.graph.clearWorkspace.confirmButtonLabel": "清除工作空间",
@ -5514,14 +5522,6 @@
"xpack.logstash.upgradeFailureActions.goBackButtonLabel": "返回",
"xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage": "upstreamPipeline 参数必须包含 id 属性",
"xpack.logstash.workersTooltip": "并行执行管道的筛选和输出阶段的工作线程数目。如果您发现事件出现积压或 CPU 未饱和,请考虑增大此数值,以更好地利用机器处理能力。\n\n默认值主机的 CPU 核心数",
"xpack.main.featureRegistry.advancedSettingsFeatureName": "高级设置",
"xpack.main.featureRegistry.dashboardFeatureName": "仪表板",
"xpack.main.featureRegistry.devToolsFeatureName": "开发工具",
"xpack.main.featureRegistry.devToolsPrivilegesTooltip": "还应向用户授予适当的 Elasticsearch 集群和索引权限",
"xpack.main.featureRegistry.discoverFeatureName": "Discover",
"xpack.main.featureRegistry.indexPatternFeatureName": "索引模式管理",
"xpack.main.featureRegistry.savedObjectsManagementFeatureName": "已保存对象管理",
"xpack.main.featureRegistry.visualizeFeatureName": "可视化",
"xpack.main.welcomeBanner.licenseIsExpiredDescription": "联系您的管理员或直接{updateYourLicenseLinkText}。",
"xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可",
"xpack.main.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期",

View file

@ -6,7 +6,7 @@
import expect from '@kbn/expect';
import { SecurityService } from '../../../../common/services';
import { Feature } from '../../../../../legacy/plugins/xpack_main/types';
import { Feature } from '../../../../../plugins/features/server';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function({ getService }: FtrProviderContext) {
@ -15,18 +15,6 @@ export default function({ getService }: FtrProviderContext) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const security: SecurityService = getService('security');
const expect404 = (result: any) => {
expect(result.error).to.be(undefined);
expect(result.response).not.to.be(undefined);
expect(result.response).to.have.property('statusCode', 404);
};
const expect200 = (result: any) => {
expect(result.error).to.be(undefined);
expect(result.response).not.to.be(undefined);
expect(result.response).to.have.property('statusCode', 200);
};
describe('/api/features', () => {
describe('with the "global all" privilege', () => {
it('should return a 200', async () => {
@ -50,14 +38,11 @@ export default function({ getService }: FtrProviderContext) {
full_name: 'a kibana user',
});
const result = await supertestWithoutAuth
.get(`/api/features/v1`)
await supertestWithoutAuth
.get('/api/features')
.auth(username, password)
.set('kbn-xsrf', 'foo')
.then((response: any) => ({ error: undefined, response }))
.catch((error: any) => ({ error, response: undefined }));
expect200(result);
.expect(200);
} finally {
await security.role.delete(roleName);
await security.user.delete(username);
@ -88,14 +73,11 @@ export default function({ getService }: FtrProviderContext) {
full_name: 'a kibana user',
});
const result = await supertestWithoutAuth
.get(`/api/features/v1`)
await supertestWithoutAuth
.get('/api/features')
.auth(username, password)
.set('kbn-xsrf', 'foo')
.then((response: any) => ({ error: undefined, response }))
.catch((error: any) => ({ error, response: undefined }));
expect404(result);
.expect(404);
} finally {
await security.role.delete(roleName);
await security.user.delete(username);
@ -106,7 +88,7 @@ export default function({ getService }: FtrProviderContext) {
describe('with trial license', () => {
it('should return a full feature set', async () => {
const { body } = await supertest
.get('/api/features/v1')
.get('/api/features')
.set('kbn-xsrf', 'xxx')
.expect(200);

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export {
Feature,
FeatureKibanaPrivileges,
FeatureRegistry,
FeatureWithAllOrReadPrivileges,
uiCapabilitiesRegex,
} from './feature_registry';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function({ loadTestFile }: FtrProviderContext) {
describe('features', () => {
loadTestFile(require.resolve('./features'));
});
}

View file

@ -13,6 +13,7 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./spaces'));
loadTestFile(require.resolve('./monitoring'));
loadTestFile(require.resolve('./xpack_main'));
loadTestFile(require.resolve('./features'));
loadTestFile(require.resolve('./telemetry'));
loadTestFile(require.resolve('./logstash'));
loadTestFile(require.resolve('./kibana'));

View file

@ -6,7 +6,6 @@
export default function ({ loadTestFile }) {
describe('xpack_main', () => {
loadTestFile(require.resolve('./features'));
loadTestFile(require.resolve('./settings'));
});
}

View file

@ -23,8 +23,8 @@ export class FeaturesService {
}
public async get(): Promise<Features> {
this.log.debug(`requesting /api/features/v1 to get the features`);
const response = await this.axios.get('/api/features/v1');
this.log.debug('requesting /api/features to get the features');
const response = await this.axios.get('/api/features');
if (response.status !== 200) {
throw new Error(