mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
## Summary This change is the implementation of the `Kibana Privilege Migrations` proposal/RFC and provides a framework that allows developers to replace an existing feature with a new one that has the desired configuration while teaching the platform how the privileges of the deprecated feature can be represented by non-deprecated ones. This approach avoids introducing breaking changes for users who still rely on the deprecated privileges in their existing roles and any automation. Among the use cases the framework is supposed to handle, the most common are the following: * Changing a feature ID from `Alpha` to `Beta` * Splitting a feature `Alpha` into two features, `Beta` and `Gamma` * Moving a capability between privileges within a feature (top-level or sub-feature) * Consolidating capabilities across independent features ## Scope This PR includes only the core functionality proposed in the RFC and most of the necessary guardrails (tests, early validations, etc.) to help engineers start planning and implementing their migrations as soon as possible. The following functionality will be added in follow-ups or once we collect enough feedback: * Telemetry * Developer documentation * UI enhancements (highlighting roles with deprecated privileges and manual migration actions) ## Framework The steps below use a scenario where a feature `Alpha` should be split into two other features `Beta` and `Gamma` as an example. ### Step 1: Create new features with the desired privileges First of all, define new feature or features with the desired configuration as you'd do before. There are no constraints here. <details> <summary>Click to see the code</summary> ```ts deps.features.registerKibanaFeature({ id: 'feature_beta', name: 'Feature Beta', privileges: { all: { savedObject: { all: ['saved_object_1'], read: [] }, ui: ['ui_all'], api: ['api_all'], … omitted for brevity … }, read: { savedObject: { all: [], read: ['saved_object_1'] }, ui: ['ui_read'], api: ['api_read'], … omitted for brevity … }, }, … omitted for brevity … }); deps.features.registerKibanaFeature({ id: 'feature_gamma', name: 'Feature Gamma', privileges: { all: { savedObject: { all: ['saved_object_2'], read: [] }, ui: ['ui_all'], // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags … omitted for brevity … }, read: { savedObject: { all: [], read: ['saved_object_2'] }, ui: ['ui_read'], // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags … omitted for brevity … }, }, … omitted for brevity … }); ``` </details> ### Step 2: Mark existing feature as deprecated Once a feature is marked as deprecated, it should essentially be treated as frozen for backward compatibility reasons. Deprecated features will no longer be available through the Kibana role management UI and will be replaced with non-deprecated privileges. Deprecated privileges will still be accepted if the role is created or updated via the Kibana role management APIs to avoid disrupting existing user automation. To avoid breaking existing roles that reference privileges provided by the deprecated features, Kibana will continue registering these privileges as Elasticsearch application privileges. <details> <summary>Click to see the code</summary> ```ts deps.features.registerKibanaFeature({ // This is a new `KibanaFeature` property available during feature registration. deprecated: { // User-facing justification for privilege deprecation that we can display // to the user when we ask them to perform role migration. notice: i18n.translate('xpack.security...', { defaultMessage: "Feature Alpha is deprecated, refer to {link}...", values: { link: docLinks.links.security.deprecatedFeatureAlpha }, }) }, // Feature id should stay unchanged, and it's not possible to reuse it. id: 'feature_alpha', name: 'Feature Alpha (DEPRECATED)', privileges: { all: { savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] }, ui: ['ui_all'], api: ['api_all'], … omitted for brevity … }, read: { savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] }, ui: ['ui_read'], api: ['api_read'], … omitted for brevity … }, }, … omitted for brevity … }); ``` </details> ### Step 3: Map deprecated feature’s privileges to the privileges of the non-deprecated features The important requirement for a successful migration from a deprecated feature to a new feature or features is that it should be possible to express **any combination** of the deprecated feature and sub-feature privileges with the feature or sub-feature privileges of non-deprecated features. This way, while editing a role with deprecated feature privileges in the UI, the admin will be interacting with new privileges as if they were creating a new role from scratch, maintaining consistency. The relationship between the privileges of the deprecated feature and the privileges of the features that are supposed to replace them is expressed with a new `replacedBy` property available on the privileges of the deprecated feature. <details> <summary>Click to see the code</summary> ```ts deps.features.registerKibanaFeature({ // This is a new `KibanaFeature` property available during feature registration. deprecated: { // User-facing justification for privilege deprecation that we can display // to the user when we ask them to perform role migration. notice: i18n.translate('xpack.security...', { defaultMessage: "Feature Alpha is deprecated, refer to {link}...", values: { link: docLinks.links.security.deprecatedFeatureAlpha }, }) }, // Feature id should stay unchanged, and it's not possible to reuse it. id: 'feature_alpha', name: 'Feature Alpha (DEPRECATED)', privileges: { all: { savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] }, ui: ['ui_all'], api: ['api_all'], replacedBy: [ { feature: 'feature_beta', privileges: ['all'] }, { feature: 'feature_gamma', privileges: ['all'] }, ], … omitted for brevity … }, read: { savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] }, ui: ['ui_read'], api: ['api_read'], replacedBy: [ { feature: 'feature_beta', privileges: ['read'] }, { feature: 'feature_gamma', privileges: ['read'] }, ], … omitted for brevity … }, }, … omitted for brevity … }); ``` </details> ### Step 4: Adjust the code to rely only on new, non-deprecated features Special care should be taken if the replacement privileges cannot reuse the API access tags from the deprecated privileges and introduce new tags that will be applied to the same API endpoints. In this case, developers should replace the API access tags of the deprecated privileges with the corresponding tags provided by the replacement privileges. This is necessary because API endpoints can only be accessed if the user privileges cover all the tags listed in the API endpoint definition, and without these changes, existing roles referencing deprecated privileges won’t be able to access those endpoints. The UI capabilities are handled slightly differently because they are always prefixed with the feature ID. When migrating to new features with new IDs, the code that interacts with UI capabilities will be updated to use these new feature IDs. <details> <summary>Click to see the code</summary> ```ts // BEFORE deprecation/migration // 1. Feature Alpha defition (not deprecated yet) deps.features.registerKibanaFeature({ id: 'feature_alpha', privileges: { all: { api: ['api_all'], … omitted for brevity … }, }, … omitted for brevity … }); // 2. Route protected by `all` privilege of the Feature Alpha router.post( { path: '/api/domain/my_api', options: { tags: ['access:api_all'] } }, async (_context, request, response) => {} ); // AFTER deprecation/migration // 1. Feature Alpha defition (deprecated, with updated API tags) deps.features.registerKibanaFeature({ deprecated: …, id: 'feature_alpha', privileges: { all: { api: ['api_all_v2'], replacedBy: [ { feature: 'feature_beta', privileges: ['all'] }, ], … omitted for brevity … }, }, … omitted for brevity … }); // 2. Feature Beta defition (new) deps.features.registerKibanaFeature({ id: 'feature_beta', privileges: { all: { api: ['api_all_v2'], … omitted for brevity … } }, … omitted for brevity … }); // 3. Route protected by `all` privilege of the Feature Alpha OR Feature Beta router.post( { path: '/api/domain/my_api', options: { tags: ['access:api_all_v2'] } }, async (_context, request, response) => {} ); ---- // ❌ Old client-side code (supports only deprecated privileges) if (capabilities.feature_alpha.ui_all) { … omitted for brevity … } // ✅ New client-side code (will work for **both** new and deprecated privileges) if (capabilities.feature_beta.ui_all) { … omitted for brevity … } ``` </details> ## How to test The code introduces a set of API integration tests that are designed to validate whether the privilege mapping between deprecated and replacement privileges maintains backward compatibility. You can run the test server with the following config to register a number of [example deprecated features](https://github.com/elastic/kibana/pull/186800/files#diff-d887981d43bbe30cda039340b906b0fa7649ba80230be4de8eda326036f10f6fR20-R49)(`x-pack/test/security_api_integration/plugins/features_provider/server/index.ts`) and the features that replace them, to see the framework in action: ```bash node scripts/functional_tests_server.js --config x-pack/test/security_api_integration/features.config.ts ``` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
/*
|
|
* 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 util from 'util';
|
|
import { isEqual, isEqualWith } from 'lodash';
|
|
import expect from '@kbn/expect';
|
|
import { RawKibanaPrivileges } from '@kbn/security-plugin-types-common';
|
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
|
|
|
export default function ({ getService }: FtrProviderContext) {
|
|
const supertest = getService('supertest');
|
|
|
|
const expectedWithoutActions = {
|
|
global: ['all', 'read'],
|
|
space: ['all', 'read'],
|
|
features: {
|
|
graph: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
canvas: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
generalCases: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'cases_delete',
|
|
'cases_settings',
|
|
],
|
|
observabilityCases: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'cases_delete',
|
|
'cases_settings',
|
|
],
|
|
observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
slo: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
actions: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
ml: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
siem: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'endpoint_list_all',
|
|
'endpoint_list_read',
|
|
'trusted_applications_all',
|
|
'trusted_applications_read',
|
|
'host_isolation_exceptions_all',
|
|
'host_isolation_exceptions_read',
|
|
'blocklist_all',
|
|
'blocklist_read',
|
|
'event_filters_all',
|
|
'event_filters_read',
|
|
'policy_management_all',
|
|
'policy_management_read',
|
|
'actions_log_management_all',
|
|
'actions_log_management_read',
|
|
'host_isolation_all',
|
|
'process_operations_all',
|
|
'file_operations_all',
|
|
'execute_operations_all',
|
|
'scan_operations_all',
|
|
],
|
|
uptime: ['all', 'read', 'minimal_all', 'minimal_read', 'elastic_managed_locations_enabled'],
|
|
securitySolutionAssistant: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'update_anonymization',
|
|
],
|
|
securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
securitySolutionCases: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'cases_delete',
|
|
'cases_settings',
|
|
],
|
|
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
apm: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
discover: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'url_create',
|
|
'store_search_session',
|
|
],
|
|
visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'],
|
|
dashboard: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'url_create',
|
|
'store_search_session',
|
|
],
|
|
dev_tools: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
osquery: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'live_queries_all',
|
|
'live_queries_read',
|
|
'run_saved_queries',
|
|
'saved_queries_all',
|
|
'saved_queries_read',
|
|
'packs_all',
|
|
'packs_read',
|
|
],
|
|
enterpriseSearch: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
rulesSettings: [
|
|
'all',
|
|
'read',
|
|
'minimal_all',
|
|
'minimal_read',
|
|
'allFlappingSettings',
|
|
'readFlappingSettings',
|
|
],
|
|
maintenanceWindow: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
guidedOnboardingFeature: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
inventory: ['all', 'read', 'minimal_all', 'minimal_read'],
|
|
},
|
|
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
|
|
};
|
|
|
|
describe('Privileges', () => {
|
|
describe('GET /api/security/privileges', () => {
|
|
it('should return a privilege map with all known privileges, without actions', async () => {
|
|
// If you're adding a privilege to the following, that's great!
|
|
// If you're removing a privilege, this breaks backwards compatibility
|
|
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
|
|
|
|
await supertest
|
|
.get('/api/security/privileges')
|
|
.set('kbn-xsrf', 'xxx')
|
|
.send()
|
|
.expect(200)
|
|
.expect((res: any) => {
|
|
// when comparing privileges, the order of the features doesn't matter (but the order of the privileges does)
|
|
// supertest uses assert.deepStrictEqual.
|
|
// expect.js doesn't help us here.
|
|
// and lodash's isEqual doesn't know how to compare Sets.
|
|
const success = isEqualWith(res.body, expectedWithoutActions, (value, other, key) => {
|
|
if (Array.isArray(value) && Array.isArray(other)) {
|
|
if (key === 'reserved') {
|
|
// order does not matter for the reserved privilege set.
|
|
return isEqual(value.sort(), other.sort());
|
|
}
|
|
// order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness.
|
|
return isEqual(value, other);
|
|
}
|
|
|
|
// Lodash types aren't correct, `undefined` should be supported as a return value here and it
|
|
// has special meaning.
|
|
return undefined as any;
|
|
});
|
|
|
|
if (!success) {
|
|
throw new Error(
|
|
`Expected ${util.inspect(res.body)} to equal ${util.inspect(
|
|
expectedWithoutActions
|
|
)}`
|
|
);
|
|
}
|
|
})
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/security/privileges?includeActions=true', () => {
|
|
// The UI assumes that no wildcards are present when calculating the effective set of privileges.
|
|
// If this changes, then the "privilege calculators" will need revisiting to account for these wildcards.
|
|
it('should return a privilege map with actions which do not include wildcards', async () => {
|
|
await supertest
|
|
.get('/api/security/privileges?includeActions=true')
|
|
.set('kbn-xsrf', 'xxx')
|
|
.send()
|
|
.expect(200)
|
|
.expect((res: any) => {
|
|
const { features, global, space, reserved } = res.body as RawKibanaPrivileges;
|
|
expect(features).to.be.an('object');
|
|
expect(global).to.be.an('object');
|
|
expect(space).to.be.an('object');
|
|
expect(reserved).to.be.an('object');
|
|
|
|
Object.entries(features).forEach(([featureId, featurePrivs]) => {
|
|
Object.values(featurePrivs).forEach((actions) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Feature ${featureId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
Object.entries(global).forEach(([privilegeId, actions]) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Global privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
|
|
Object.entries(space).forEach(([privilegeId, actions]) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Space privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
|
|
Object.entries(reserved).forEach(([privilegeId, actions]) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag
|
|
describe('GET /api/security/privileges?respectLicenseLevel=false', () => {
|
|
it('should return a privilege map with all known privileges, without actions', async () => {
|
|
// If you're adding a privilege to the following, that's great!
|
|
// If you're removing a privilege, this breaks backwards compatibility
|
|
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
|
|
|
|
await supertest
|
|
.get('/api/security/privileges?respectLicenseLevel=false')
|
|
.set('kbn-xsrf', 'xxx')
|
|
.send()
|
|
.expect(200)
|
|
.expect((res: any) => {
|
|
// when comparing privileges, the order of the features doesn't matter (but the order of the privileges does)
|
|
// supertest uses assert.deepStrictEqual.
|
|
// expect.js doesn't help us here.
|
|
// and lodash's isEqual doesn't know how to compare Sets.
|
|
const success = isEqualWith(res.body, expectedWithoutActions, (value, other, key) => {
|
|
if (Array.isArray(value) && Array.isArray(other)) {
|
|
if (key === 'reserved') {
|
|
// order does not matter for the reserved privilege set.
|
|
return isEqual(value.sort(), other.sort());
|
|
}
|
|
// order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness.
|
|
return isEqual(value, other);
|
|
}
|
|
|
|
// Lodash types aren't correct, `undefined` should be supported as a return value here and it
|
|
// has special meaning.
|
|
return undefined as any;
|
|
});
|
|
|
|
if (!success) {
|
|
throw new Error(
|
|
`Expected ${util.inspect(res.body)} to equal ${util.inspect(
|
|
expectedWithoutActions
|
|
)}`
|
|
);
|
|
}
|
|
})
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
// In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag
|
|
describe('GET /api/security/privileges?includeActions=true&respectLicenseLevel=false', () => {
|
|
// The UI assumes that no wildcards are present when calculating the effective set of privileges.
|
|
// If this changes, then the "privilege calculators" will need revisiting to account for these wildcards.
|
|
it('should return a privilege map with actions which do not include wildcards', async () => {
|
|
await supertest
|
|
.get('/api/security/privileges?includeActions=true')
|
|
.set('kbn-xsrf', 'xxx')
|
|
.send()
|
|
.expect(200)
|
|
.expect((res: any) => {
|
|
const { features, global, space, reserved } = res.body as RawKibanaPrivileges;
|
|
expect(features).to.be.an('object');
|
|
expect(global).to.be.an('object');
|
|
expect(space).to.be.an('object');
|
|
expect(reserved).to.be.an('object');
|
|
|
|
Object.entries(features).forEach(([featureId, featurePrivs]) => {
|
|
Object.values(featurePrivs).forEach((actions) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Feature ${featureId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
Object.entries(global).forEach(([privilegeId, actions]) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Global privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
|
|
Object.entries(space).forEach(([privilegeId, actions]) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Space privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
|
|
Object.entries(reserved).forEach(([privilegeId, actions]) => {
|
|
expect(actions).to.be.an('array');
|
|
actions.forEach((action) => {
|
|
expect(action).to.be.a('string');
|
|
expect(action.indexOf('*')).to.eql(
|
|
-1,
|
|
`Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|