[8.x] fix(security, features): do not expose UI capabilities of the deprecated features (#198656) (#199147)

# Backport

This will backport the following commits from `main` to `8.x`:
- [fix(security, features): do not expose UI capabilities of the
deprecated features
(#198656)](https://github.com/elastic/kibana/pull/198656)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Aleh
Zasypkin","email":"aleh.zasypkin@elastic.co"},"sourceCommit":{"committedDate":"2024-11-06T14:06:39Z","message":"fix(security,
features): do not expose UI capabilities of the deprecated features
(#198656)\n\n## Summary\r\n\r\nThis PR ensures that we don’t expose UI
capabilities for deprecated\r\nfeatures since they’re unnecessary, and
the code should rely on the UI\r\ncapabilities of the replacement
features instead.\r\n\r\nAdditionally, this PR transforms the
`disabledFeatures` property of\r\nSpace objects returned from our
programmatic and HTTP APIs to replace\r\nany deprecated feature IDs with
the IDs of their replacement features,\r\nensuring that feature
visibility toggles work for deprecated features as\r\nwell.\r\n\r\n##
How to test\r\n\r\n1. Run Kibana FTR server with the following config
(registers test\r\ndeprecated features):\r\n```shell\r\nnode
scripts/functional_tests_server.js --config
x-pack/test/security_api_integration/features.config.ts\r\n```\r\n2.
Once server is up and running create Space with
the\r\n`case_1_feature_a` **deprecated** feature
disabled:\r\n```shell\r\ncurl 'http://localhost:5620/api/spaces/space'
-u elastic:changeme \\\r\n -X POST -H 'Content-Type: application/json'
-H 'kbn-version: 9.0.0' \\\r\n --data-raw
'{\"name\":\"space-alpha\",\"id\":\"space-alpha\",\"initials\":\"s\",\"color\":\"#D6BF57\",\"disabledFeatures\":[\"case_1_feature_a\"],\"imageUrl\":\"\"}'\r\n```\r\n3.
Log in to Kibana and [navigate to a
Space\r\n`space-alpha`](http://localhost:5620/app/management/kibana/spaces/edit/space-alpha)\r\nyou've
just created. Observe that deprecated `Case #1 feature
A`\r\n(`case_1_feature_a`) isn't displayed, and instead you should see
that\r\nreplaces deprecated one - `Case #1 feature B`
(`case_1_feature_b`):\r\n\r\n![Screen Shot 2024-11-01 at 17
40\r\n59](https://github.com/user-attachments/assets/5b91e71c-7d46-4ff1-bf73-d148622e8ec4)\r\n\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"deeb9fe32af717a883727aed7d83c6106d8d839f","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","release_note:skip","Feature:Security/Authorization","v9.0.0","backport:prev-major"],"title":"fix(security,
features): do not expose UI capabilities of the deprecated
features","number":198656,"url":"https://github.com/elastic/kibana/pull/198656","mergeCommit":{"message":"fix(security,
features): do not expose UI capabilities of the deprecated features
(#198656)\n\n## Summary\r\n\r\nThis PR ensures that we don’t expose UI
capabilities for deprecated\r\nfeatures since they’re unnecessary, and
the code should rely on the UI\r\ncapabilities of the replacement
features instead.\r\n\r\nAdditionally, this PR transforms the
`disabledFeatures` property of\r\nSpace objects returned from our
programmatic and HTTP APIs to replace\r\nany deprecated feature IDs with
the IDs of their replacement features,\r\nensuring that feature
visibility toggles work for deprecated features as\r\nwell.\r\n\r\n##
How to test\r\n\r\n1. Run Kibana FTR server with the following config
(registers test\r\ndeprecated features):\r\n```shell\r\nnode
scripts/functional_tests_server.js --config
x-pack/test/security_api_integration/features.config.ts\r\n```\r\n2.
Once server is up and running create Space with
the\r\n`case_1_feature_a` **deprecated** feature
disabled:\r\n```shell\r\ncurl 'http://localhost:5620/api/spaces/space'
-u elastic:changeme \\\r\n -X POST -H 'Content-Type: application/json'
-H 'kbn-version: 9.0.0' \\\r\n --data-raw
'{\"name\":\"space-alpha\",\"id\":\"space-alpha\",\"initials\":\"s\",\"color\":\"#D6BF57\",\"disabledFeatures\":[\"case_1_feature_a\"],\"imageUrl\":\"\"}'\r\n```\r\n3.
Log in to Kibana and [navigate to a
Space\r\n`space-alpha`](http://localhost:5620/app/management/kibana/spaces/edit/space-alpha)\r\nyou've
just created. Observe that deprecated `Case #1 feature
A`\r\n(`case_1_feature_a`) isn't displayed, and instead you should see
that\r\nreplaces deprecated one - `Case #1 feature B`
(`case_1_feature_b`):\r\n\r\n![Screen Shot 2024-11-01 at 17
40\r\n59](https://github.com/user-attachments/assets/5b91e71c-7d46-4ff1-bf73-d148622e8ec4)\r\n\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"deeb9fe32af717a883727aed7d83c6106d8d839f"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/198656","number":198656,"mergeCommit":{"message":"fix(security,
features): do not expose UI capabilities of the deprecated features
(#198656)\n\n## Summary\r\n\r\nThis PR ensures that we don’t expose UI
capabilities for deprecated\r\nfeatures since they’re unnecessary, and
the code should rely on the UI\r\ncapabilities of the replacement
features instead.\r\n\r\nAdditionally, this PR transforms the
`disabledFeatures` property of\r\nSpace objects returned from our
programmatic and HTTP APIs to replace\r\nany deprecated feature IDs with
the IDs of their replacement features,\r\nensuring that feature
visibility toggles work for deprecated features as\r\nwell.\r\n\r\n##
How to test\r\n\r\n1. Run Kibana FTR server with the following config
(registers test\r\ndeprecated features):\r\n```shell\r\nnode
scripts/functional_tests_server.js --config
x-pack/test/security_api_integration/features.config.ts\r\n```\r\n2.
Once server is up and running create Space with
the\r\n`case_1_feature_a` **deprecated** feature
disabled:\r\n```shell\r\ncurl 'http://localhost:5620/api/spaces/space'
-u elastic:changeme \\\r\n -X POST -H 'Content-Type: application/json'
-H 'kbn-version: 9.0.0' \\\r\n --data-raw
'{\"name\":\"space-alpha\",\"id\":\"space-alpha\",\"initials\":\"s\",\"color\":\"#D6BF57\",\"disabledFeatures\":[\"case_1_feature_a\"],\"imageUrl\":\"\"}'\r\n```\r\n3.
Log in to Kibana and [navigate to a
Space\r\n`space-alpha`](http://localhost:5620/app/management/kibana/spaces/edit/space-alpha)\r\nyou've
just created. Observe that deprecated `Case #1 feature
A`\r\n(`case_1_feature_a`) isn't displayed, and instead you should see
that\r\nreplaces deprecated one - `Case #1 feature B`
(`case_1_feature_b`):\r\n\r\n![Screen Shot 2024-11-01 at 17
40\r\n59](https://github.com/user-attachments/assets/5b91e71c-7d46-4ff1-bf73-d148622e8ec4)\r\n\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"deeb9fe32af717a883727aed7d83c6106d8d839f"}}]}]
BACKPORT-->

Co-authored-by: Aleh Zasypkin <aleh.zasypkin@elastic.co>
This commit is contained in:
Kibana Machine 2024-11-07 02:52:51 +11:00 committed by GitHub
parent bee5c9d415
commit 5d674b87f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 301 additions and 42 deletions

View file

@ -25,8 +25,8 @@ const createSetup = (): jest.Mocked<FeaturesPluginSetup> => {
const createStart = (): jest.Mocked<FeaturesPluginStart> => {
return {
getKibanaFeatures: jest.fn(),
getElasticsearchFeatures: jest.fn(),
getKibanaFeatures: jest.fn().mockReturnValue([]),
getElasticsearchFeatures: jest.fn().mockReturnValue([]),
};
};

View file

@ -138,7 +138,8 @@ export class FeaturesPlugin
this.featureRegistry.validateFeatures();
this.capabilities = uiCapabilitiesForFeatures(
this.featureRegistry.getAllKibanaFeatures(),
// Don't expose capabilities of the deprecated features.
this.featureRegistry.getAllKibanaFeatures({ omitDeprecated: true }),
this.featureRegistry.getAllElasticsearchFeatures()
);

View file

@ -145,12 +145,9 @@ describe('#start', () => {
customBranding: mockCoreSetup.customBranding,
});
const featuresStart = featuresPluginMock.createStart();
featuresStart.getKibanaFeatures.mockReturnValue([]);
authorizationService.start({
clusterClient: mockClusterClient,
features: featuresStart,
features: featuresPluginMock.createStart(),
online$: statusSubject.asObservable(),
});
@ -217,12 +214,9 @@ it('#stop unsubscribes from license and ES updates.', async () => {
customBranding: mockCoreSetup.customBranding,
});
const featuresStart = featuresPluginMock.createStart();
featuresStart.getKibanaFeatures.mockReturnValue([]);
authorizationService.start({
clusterClient: mockClusterClient,
features: featuresStart,
features: featuresPluginMock.createStart(),
online$: statusSubject.asObservable(),
});

View file

@ -64,10 +64,8 @@ describe('Security Plugin', () => {
mockCoreStart = coreMock.createStart();
const mockFeaturesStart = featuresPluginMock.createStart();
mockFeaturesStart.getKibanaFeatures.mockReturnValue([]);
mockStartDependencies = {
features: mockFeaturesStart,
features: featuresPluginMock.createStart(),
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
};

View file

@ -66,7 +66,7 @@ const features = [
category: { id: 'securitySolution' },
},
{
// feature 4 intentionally delcares the same items as feature 3
// feature 4 intentionally declares the same items as feature 3
id: 'feature_4',
name: 'Feature 4',
app: ['feature3', 'feature3_app'],
@ -87,6 +87,32 @@ const features = [
},
category: { id: 'observability' },
},
{
deprecated: { notice: 'It was a mistake.' },
id: 'deprecated_feature',
name: 'Deprecated Feature',
// Expose the same `app` and `catalogue` entries as `feature_2` to make sure they are disabled
// when `feature_2` is disabled even if the deprecated feature isn't explicitly disabled.
app: ['feature2'],
catalogue: ['feature2Entry'],
category: { id: 'deprecated', label: 'deprecated' },
privileges: {
all: {
savedObject: { all: [], read: [] },
ui: ['ui_deprecated_all'],
app: ['feature2'],
catalogue: ['feature2Entry'],
replacedBy: [{ feature: 'feature_2', privileges: ['all'] }],
},
read: {
savedObject: { all: [], read: [] },
ui: ['ui_deprecated_read'],
app: ['feature2'],
catalogue: ['feature2Entry'],
replacedBy: [{ feature: 'feature_2', privileges: ['all'] }],
},
},
},
] as unknown as KibanaFeature[];
const buildCapabilities = () =>

View file

@ -72,7 +72,7 @@ function toggleDisabledFeatures(
(acc, feature) => {
if (disabledFeatureKeys.includes(feature.id)) {
acc.disabledFeatures.push(feature);
} else {
} else if (!feature.deprecated) {
acc.enabledFeatures.push(feature);
}
return acc;

View file

@ -39,6 +39,7 @@ const enabledFeaturesPerSolution: Record<SolutionId, string[]> = {
* This function takes the current space's disabled features and the space solution and returns
* the updated array of disabled features.
*
* @param features The list of all Kibana registered features.
* @param spaceDisabledFeatures The current space's disabled features
* @param spaceSolution The current space's solution (es, oblt, security or classic)
* @returns The updated array of disabled features

View file

@ -56,13 +56,9 @@ describe('Spaces Public API', () => {
basePath: httpService.basePath,
});
const featuresPluginMockStart = featuresPluginMock.createStart();
featuresPluginMockStart.getKibanaFeatures.mockReturnValue([]);
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart, featuresPluginMockStart);
const clientServiceStart = clientService.start(coreStart, featuresPluginMock.createStart());
const spacesServiceStart = service.start({
basePath: coreStart.http.basePath,

View file

@ -56,13 +56,9 @@ describe('PUT /api/spaces/space', () => {
basePath: httpService.basePath,
});
const featuresPluginMockStart = featuresPluginMock.createStart();
featuresPluginMockStart.getKibanaFeatures.mockReturnValue([]);
const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract());
const clientServiceStart = clientService.start(coreStart, featuresPluginMockStart);
const clientServiceStart = clientService.start(coreStart, featuresPluginMock.createStart());
const spacesServiceStart = service.start({
basePath: coreStart.http.basePath,

View file

@ -55,6 +55,37 @@ const features = [
catalogue: ['feature3Entry'],
category: { id: 'securitySolution' },
},
{
deprecated: { notice: 'It was a mistake.' },
id: 'feature_4_deprecated',
name: 'Deprecated Feature',
app: ['feature2', 'feature3'],
catalogue: ['feature2Entry', 'feature3Entry'],
category: { id: 'deprecated', label: 'deprecated' },
scope: ['spaces', 'security'],
privileges: {
all: {
savedObject: { all: [], read: [] },
ui: [],
app: ['feature2', 'feature3'],
catalogue: ['feature2Entry', 'feature3Entry'],
replacedBy: [
{ feature: 'feature_2', privileges: ['all'] },
{ feature: 'feature_3', privileges: ['all'] },
],
},
read: {
savedObject: { all: [], read: [] },
ui: [],
app: ['feature2', 'feature3'],
catalogue: ['feature2Entry', 'feature3Entry'],
replacedBy: [
{ feature: 'feature_2', privileges: ['read'] },
{ feature: 'feature_3', privileges: ['read'] },
],
},
},
},
] as unknown as KibanaFeature[];
const featuresStart = featuresPluginMock.createStart();
@ -103,6 +134,17 @@ describe('#getAll', () => {
bar: 'baz-bar', // an extra attribute that will be ignored during conversion
},
},
{
// alpha only has deprecated disabled features
id: 'alpha',
type: 'space',
references: [],
attributes: {
name: 'alpha-name',
description: 'alpha-description',
disabledFeatures: ['feature_1', 'feature_4_deprecated'],
},
},
];
const expectedSpaces: Space[] = [
@ -130,6 +172,12 @@ describe('#getAll', () => {
description: 'baz-description',
disabledFeatures: [],
},
{
id: 'alpha',
name: 'alpha-name',
description: 'alpha-description',
disabledFeatures: ['feature_1', 'feature_2', 'feature_3'],
},
];
test(`finds spaces using callWithRequestRepository`, async () => {

View file

@ -14,6 +14,7 @@ import type {
SavedObject,
} from '@kbn/core/server';
import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common';
import type { KibanaFeature } from '@kbn/features-plugin/common';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import type { FeaturesPluginStart } from '@kbn/features-plugin/server';
@ -84,7 +85,13 @@ export interface ISpacesClient {
* Client for interacting with spaces.
*/
export class SpacesClient implements ISpacesClient {
private isServerless = false;
private readonly isServerless: boolean;
/**
* A map of deprecated feature IDs to the feature IDs that replace them used to transform the disabled features
* of a space to make sure they only reference non-deprecated features.
*/
private readonly deprecatedFeaturesReferences: Map<string, Set<string>>;
constructor(
private readonly debugLogger: (message: string) => void,
@ -95,6 +102,9 @@ export class SpacesClient implements ISpacesClient {
private readonly features: FeaturesPluginStart
) {
this.isServerless = this.buildFlavour === 'serverless';
this.deprecatedFeaturesReferences = this.collectDeprecatedFeaturesReferences(
features.getKibanaFeatures()
);
}
public async getAll(options: v1.GetAllSpacesOptions = {}): Promise<v1.GetSpaceResult[]> {
@ -247,6 +257,8 @@ export class SpacesClient implements ISpacesClient {
};
private transformSavedObjectToSpace = (savedObject: SavedObject<any>): v1.Space => {
// Solution isn't supported in the serverless offering.
const solution = !this.isServerless ? savedObject.attributes.solution : undefined;
return {
id: savedObject.id,
name: savedObject.attributes.name ?? '',
@ -256,11 +268,13 @@ export class SpacesClient implements ISpacesClient {
imageUrl: savedObject.attributes.imageUrl,
disabledFeatures: withSpaceSolutionDisabledFeatures(
this.features.getKibanaFeatures(),
savedObject.attributes.disabledFeatures ?? [],
!this.isServerless ? savedObject.attributes.solution : undefined
savedObject.attributes.disabledFeatures?.flatMap((featureId: string) =>
Array.from(this.deprecatedFeaturesReferences.get(featureId) ?? [featureId])
) ?? [],
solution
),
_reserved: savedObject.attributes._reserved,
...(!this.isServerless ? { solution: savedObject.attributes.solution } : {}),
...(solution ? { solution } : {}),
} as v1.Space;
};
@ -275,4 +289,41 @@ export class SpacesClient implements ISpacesClient {
...(!this.isServerless && space.solution ? { solution: space.solution } : {}),
};
};
/**
* Collects a map of all deprecated feature IDs and the feature IDs that replace them.
* @param features A list of all available Kibana features including deprecated ones.
*/
private collectDeprecatedFeaturesReferences(features: KibanaFeature[]) {
const deprecatedFeatureReferences = new Map();
for (const feature of features) {
if (!feature.deprecated || !feature.scope?.includes(KibanaFeatureScope.Spaces)) {
continue;
}
// Collect all feature privileges including the ones provided by sub-features, if any.
const allPrivileges = Object.values(feature.privileges ?? {}).concat(
feature.subFeatures?.flatMap((subFeature) =>
subFeature.privilegeGroups.flatMap(({ privileges }) => privileges)
) ?? []
);
// Collect all features IDs that are referenced by the deprecated feature privileges.
const referencedFeaturesIds = new Set();
for (const privilege of allPrivileges) {
const replacedBy = privilege.replacedBy
? 'default' in privilege.replacedBy
? privilege.replacedBy.default.concat(privilege.replacedBy.minimal)
: privilege.replacedBy
: [];
for (const privilegeReference of replacedBy) {
referencedFeaturesIds.add(privilegeReference.feature);
}
}
deprecatedFeatureReferences.set(feature.id, referencedFeaturesIds);
}
return deprecatedFeatureReferences;
}
}

View file

@ -9,8 +9,11 @@ import type { PluginSetupContract as AlertingPluginsSetup } from '@kbn/alerting-
import { schema } from '@kbn/config-schema';
import type { CoreSetup, Plugin, PluginInitializer } from '@kbn/core/server';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import type { FeaturesPluginSetup, FeaturesPluginStart } from '@kbn/features-plugin/server';
import { initRoutes } from './init_routes';
export interface PluginSetupDependencies {
features: FeaturesPluginSetup;
alerting: AlertingPluginsSetup;
@ -23,7 +26,7 @@ export interface PluginStartDependencies {
export const plugin: PluginInitializer<void, void> = async (): Promise<
Plugin<void, void, PluginSetupDependencies, PluginStartDependencies>
> => ({
setup: (_: CoreSetup<PluginStartDependencies>, deps: PluginSetupDependencies) => {
setup: (core: CoreSetup<PluginStartDependencies>, deps: PluginSetupDependencies) => {
// Case #1: feature A needs to be renamed to feature B. It's unfortunate, but the existing feature A
// should be deprecated and re-created as a new feature with the same privileges.
case1FeatureRename(deps);
@ -46,6 +49,8 @@ export const plugin: PluginInitializer<void, void> = async (): Promise<
// * `case_4_feature_b_v2` (new, decoupled from `ab` SO, partially replaces `case_4_feature_b`)
// * `case_4_feature_c` (new, only for `ab` SO access)
case4FeatureExtract(deps);
initRoutes(core);
},
start: () => {},
stop: () => {},
@ -61,6 +66,7 @@ function case1FeatureRename(deps: PluginSetupDependencies) {
all: { savedObject: { all: ['one'], read: [] }, ui: ['ui_all'] },
read: { savedObject: { all: [], read: ['one'] }, ui: ['ui_read'] },
},
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
};
// Step 2: mark feature A as deprecated and provide proper replacements for all feature and
@ -96,6 +102,8 @@ function case2FeatureSplit(deps: PluginSetupDependencies) {
deps.features.registerKibanaFeature({
deprecated: { notice: 'Case #2 is deprecated.' },
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
app: ['app_one', 'app_two'],
catalogue: ['cat_one', 'cat_two'],
management: { kibana: ['management_one', 'management_two'] },
@ -139,6 +147,8 @@ function case2FeatureSplit(deps: PluginSetupDependencies) {
read: {
savedObject: { all: [], read: ['one', 'two'] },
ui: ['ui_read_one', 'ui_read_two'],
catalogue: ['cat_one', 'cat_two'],
app: ['app_one', 'app_two'],
replacedBy: [
{ feature: 'case_2_feature_b', privileges: ['read'] },
{ feature: 'case_2_feature_c', privileges: ['read'] },
@ -149,6 +159,8 @@ function case2FeatureSplit(deps: PluginSetupDependencies) {
// Step 2: define new features
deps.features.registerKibanaFeature({
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: 'case_2_feature_b',
name: 'Case #2 feature B',
@ -182,10 +194,14 @@ function case2FeatureSplit(deps: PluginSetupDependencies) {
read: {
savedObject: { all: [], read: ['one'] },
ui: ['ui_read_one'],
catalogue: ['cat_one'],
app: ['app_one'],
},
},
});
deps.features.registerKibanaFeature({
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: 'case_2_feature_c',
name: 'Case #2 feature C',
@ -219,6 +235,8 @@ function case2FeatureSplit(deps: PluginSetupDependencies) {
read: {
savedObject: { all: [], read: ['two'] },
ui: ['ui_read_two'],
app: ['app_two'],
catalogue: ['cat_two'],
},
},
});
@ -249,6 +267,8 @@ function case3FeatureSplitSubFeature(deps: PluginSetupDependencies) {
deps.features.registerKibanaFeature({
deprecated: { notice: 'Case #3 is deprecated.' },
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: 'case_3_feature_a',
name: 'Case #3 feature A (DEPRECATED)',
@ -275,6 +295,8 @@ function case3FeatureSplitSubFeature(deps: PluginSetupDependencies) {
// Step 2: Create a new feature with the desired privileges structure.
deps.features.registerKibanaFeature({
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: 'case_3_feature_a_v2',
name: 'Case #3 feature A',
@ -324,6 +346,8 @@ function case4FeatureExtract(deps: PluginSetupDependencies) {
deps.features.registerKibanaFeature({
deprecated: { notice: 'Case #4 is deprecated.' },
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: `case_4_feature_${suffix.toLowerCase()}`,
name: `Case #4 feature ${suffix} (DEPRECATED)`,
@ -350,6 +374,8 @@ function case4FeatureExtract(deps: PluginSetupDependencies) {
// Step 2: introduce new features (v2) with privileges that don't grant access to `ab`.
deps.features.registerKibanaFeature({
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: `case_4_feature_${suffix.toLowerCase()}_v2`,
name: `Case #4 feature ${suffix}`,
@ -363,6 +389,8 @@ function case4FeatureExtract(deps: PluginSetupDependencies) {
// Step 3: introduce new feature C that only grants access to `ab`.
deps.features.registerKibanaFeature({
scope: [KibanaFeatureScope.Security, KibanaFeatureScope.Spaces],
category: DEFAULT_APP_CATEGORIES.kibana,
id: 'case_4_feature_c',
name: 'Case #4 feature C',

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup } from '@kbn/core/server';
import type { PluginStartDependencies } from '.';
export function initRoutes(core: CoreSetup<PluginStartDependencies>) {
const router = core.http.createRouter();
// This route mirrors existing `GET /api/features` route except that it also returns all deprecated features.
router.get(
{ path: '/internal/features_provider/features', validate: false },
async (context, request, response) => {
const [, pluginDeps] = await core.getStartServices();
return response.ok({
body: pluginDeps.features.getKibanaFeatures().map((feature) => feature.toRaw()),
});
}
);
}

View file

@ -14,6 +14,7 @@ import type {
FeatureKibanaPrivilegesReference,
KibanaFeatureConfig,
} from '@kbn/features-plugin/common';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import type { Role } from '@kbn/security-plugin-types-common';
import type { FtrProviderContext } from '../../ftr_provider_context';
@ -163,11 +164,97 @@ export default function ({ getService }: FtrProviderContext) {
}
});
it('all deprecated features are known', async () => {
const { body: features } = await supertest
.get('/internal/features_provider/features')
.expect(200);
// **NOTE**: This test is to ensure the AppEx Security team has a chance to review all features marked as
// deprecated. If youre adding a new deprecated feature, make sure to add it to the list below manually or by
// running the API integration test locally with the --updateSnapshot flag.
expectSnapshot(
(features as KibanaFeatureConfig[]).flatMap((f) => (f.deprecated ? [f.id] : [])).sort()
).toMatchInline(`
Array [
"case_1_feature_a",
"case_2_feature_a",
"case_3_feature_a",
"case_4_feature_a",
"case_4_feature_b",
]
`);
});
it('all deprecated features are replaced by a single feature only', async () => {
const featuresResponse = await supertest
.get('/internal/features_provider/features')
.expect(200);
const features = featuresResponse.body as KibanaFeatureConfig[];
// **NOTE**: This test ensures that deprecated features displayed in the Spaces feature visibility toggles screen
// are only replaced by a single feature. This way, if a feature is toggled off for a particular Space, there
// wont be any ambiguity about which replacement feature should also be toggled off. Currently, we dont
// anticipate having a deprecated feature replaced by more than one feature, so this test is intended to catch
// such scenarios early. If theres a need for a deprecated feature to be replaced by multiple features, please
// reach out to the AppEx Security team to discuss how this should affect Spaces feature visibility toggles.
const featureIdsThatSupportMultipleReplacements = new Set([
'case_2_feature_a',
'case_4_feature_a',
'case_4_feature_b',
]);
for (const feature of features) {
if (
!feature.deprecated ||
!feature.scope?.includes(KibanaFeatureScope.Spaces) ||
featureIdsThatSupportMultipleReplacements.has(feature.id)
) {
continue;
}
// Collect all feature privileges including the ones provided by sub-features, if any.
const allPrivileges = Object.values(feature.privileges ?? {}).concat(
feature.subFeatures?.flatMap((subFeature) =>
subFeature.privilegeGroups.flatMap(({ privileges }) => privileges)
) ?? []
);
// Collect all features IDs that are referenced by the deprecated feature privileges.
const referencedFeaturesIds = new Set();
for (const privilege of allPrivileges) {
const replacedBy = privilege.replacedBy
? 'default' in privilege.replacedBy
? privilege.replacedBy.default.concat(privilege.replacedBy.minimal)
: privilege.replacedBy
: [];
for (const privilegeReference of replacedBy) {
referencedFeaturesIds.add(privilegeReference.feature);
}
}
if (referencedFeaturesIds.size > 1) {
throw new Error(
`Feature "${feature.id}" is deprecated and replaced by more than one feature: ${
referencedFeaturesIds.size
} features: ${Array.from(referencedFeaturesIds).join(
', '
)}. If it's intentional, please contact the AppEx Security team.`
);
}
}
});
it('all privileges of the deprecated features should have a proper replacement', async () => {
// Fetch all features first.
const featuresResponse = await supertest.get('/api/features').expect(200);
const featuresResponse = await supertest
.get('/internal/features_provider/features')
.expect(200);
const features = featuresResponse.body as KibanaFeatureConfig[];
// Check if the action provided by the deprecated feature is directly replaceable by other
// features. The `ui:`-prefixed actions are special since they are prefixed with a feature ID,
// and do not need to be replaced like any other privilege actions.
const isReplaceableAction = (action: string) => !action.startsWith('ui:');
// Collect all deprecated features.
const deprecatedFeatures = features.filter((f) => f.deprecated);
log.info(`Found ${deprecatedFeatures.length} deprecated features.`);
@ -207,7 +294,10 @@ export default function ({ getService }: FtrProviderContext) {
);
for (const deprecatedAction of deprecatedActions) {
if (!replacementActions.has(deprecatedAction)) {
if (
isReplaceableAction(deprecatedAction) &&
!replacementActions.has(deprecatedAction)
) {
throw new Error(
`Action "${deprecatedAction}" granted by the privilege "${privilegeId}" of the deprecated feature "${feature.id}" is not properly replaced.`
);
@ -225,22 +315,23 @@ export default function ({ getService }: FtrProviderContext) {
.send({ applications: [] })
.expect(200);
// Both deprecated and new UI capabilities should be toggled.
// Only new UI capabilities should be toggled, deprecated ones should not be present.
expect(capabilities).toEqual(
expect.objectContaining({
// UI flags from the deprecated feature privilege.
case_2_feature_a: {
ui_all_one: true,
ui_all_two: true,
ui_read_one: false,
ui_read_two: false,
},
// UI flags from the feature privileges that replace deprecated one.
case_2_feature_b: { ui_all_one: true, ui_read_one: false },
case_2_feature_c: { ui_all_two: true, ui_read_two: false },
})
);
for (const deprecatedFeatureId of [
'case_1_feature_a',
'case_2_feature_a',
'case_3_feature_a',
'case_4_feature_a',
'case_4_feature_b',
]) {
expect(capabilities).not.toHaveProperty(deprecatedFeatureId);
}
});
it('Cases privileges are properly handled for deprecated privileges', async () => {

View file

@ -46,6 +46,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
.filter((k) => k !== 'security')
.map((key) => `--xpack.${key}.enabled=false`),
`--plugin-path=${path.resolve(__dirname, 'plugins/foo_plugin')}`,
`--plugin-path=${path.resolve(
__dirname,
'../../security_api_integration/plugins/features_provider'
)}`,
],
},
};