kibana/x-pack/test/api_integration/apis/security/privileges.ts
Aleh Zasypkin cb2112cae5
feat: allow plugins to deprecate and replace features and feature privileges (#186800)
## 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>
2024-10-14 14:40:59 -05:00

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`
);
});
});
});
});
});
});
}