mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[8.x] feat: allow plugins to deprecate and replace features and feature privileges (#186800) (#196204)
# Backport This will backport the following commits from `main` to `8.x`: - [feat: allow plugins to deprecate and replace features and feature privileges (#186800)](https://github.com/elastic/kibana/pull/186800) <!--- Backport version: 8.9.8 --> ### 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-10-14T19:40:59Z","message":"feat: allow plugins to deprecate and replace features and feature privileges (#186800)\n\n## Summary\r\n\r\nThis change is the implementation of the `Kibana Privilege Migrations`\r\nproposal/RFC and provides a framework that allows developers to replace\r\nan existing feature with a new one that has the desired configuration\r\nwhile teaching the platform how the privileges of the deprecated feature\r\ncan be represented by non-deprecated ones. This approach avoids\r\nintroducing breaking changes for users who still rely on the deprecated\r\nprivileges in their existing roles and any automation.\r\n\r\nAmong the use cases the framework is supposed to handle, the most common\r\nare the following:\r\n\r\n* Changing a feature ID from `Alpha` to `Beta`\r\n* Splitting a feature `Alpha` into two features, `Beta` and `Gamma`\r\n* Moving a capability between privileges within a feature (top-level or\r\nsub-feature)\r\n* Consolidating capabilities across independent features\r\n\r\n## Scope\r\n\r\nThis PR includes only the core functionality proposed in the RFC and\r\nmost of the necessary guardrails (tests, early validations, etc.) to\r\nhelp engineers start planning and implementing their migrations as soon\r\nas possible. The following functionality will be added in follow-ups or\r\nonce we collect enough feedback:\r\n\r\n* Telemetry\r\n* Developer documentation\r\n* UI enhancements (highlighting roles with deprecated privileges and\r\nmanual migration actions)\r\n\r\n## Framework\r\n\r\nThe steps below use a scenario where a feature `Alpha` should be split\r\ninto two other features `Beta` and `Gamma` as an example.\r\n\r\n### Step 1: Create new features with the desired privileges\r\n\r\nFirst of all, define new feature or features with the desired\r\nconfiguration as you'd do before. There are no constraints here.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_beta',\r\n name: 'Feature Beta',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_gamma',\r\n name: 'Feature Gamma',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_2'] },\r\n ui: ['ui_read'],\r\n // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n\r\n</details>\r\n\r\n### Step 2: Mark existing feature as deprecated\r\n\r\nOnce a feature is marked as deprecated, it should essentially be treated\r\nas frozen for backward compatibility reasons. Deprecated features will\r\nno longer be available through the Kibana role management UI and will be\r\nreplaced with non-deprecated privileges.\r\n\r\nDeprecated privileges will still be accepted if the role is created or\r\nupdated via the Kibana role management APIs to avoid disrupting existing\r\nuser automation.\r\n\r\nTo avoid breaking existing roles that reference privileges provided by\r\nthe deprecated features, Kibana will continue registering these\r\nprivileges as Elasticsearch application privileges.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n // This is a new `KibanaFeature` property available during feature registration.\r\n deprecated: {\r\n // User-facing justification for privilege deprecation that we can display\r\n // to the user when we ask them to perform role migration.\r\n notice: i18n.translate('xpack.security...', {\r\n defaultMessage: \"Feature Alpha is deprecated, refer to {link}...\",\r\n values: { link: docLinks.links.security.deprecatedFeatureAlpha },\r\n })\r\n },\r\n // Feature id should stay unchanged, and it's not possible to reuse it.\r\n id: 'feature_alpha',\r\n name: 'Feature Alpha (DEPRECATED)',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n</details>\r\n\r\n### Step 3: Map deprecated feature’s privileges to the privileges of the\r\nnon-deprecated features\r\n\r\nThe important requirement for a successful migration from a deprecated\r\nfeature to a new feature or features is that it should be possible to\r\nexpress **any combination** of the deprecated feature and sub-feature\r\nprivileges with the feature or sub-feature privileges of non-deprecated\r\nfeatures. This way, while editing a role with deprecated feature\r\nprivileges in the UI, the admin will be interacting with new privileges\r\nas if they were creating a new role from scratch, maintaining\r\nconsistency.\r\n\r\nThe relationship between the privileges of the deprecated feature and\r\nthe privileges of the features that are supposed to replace them is\r\nexpressed with a new `replacedBy` property available on the privileges\r\nof the deprecated feature.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n // This is a new `KibanaFeature` property available during feature registration.\r\n deprecated: {\r\n // User-facing justification for privilege deprecation that we can display\r\n // to the user when we ask them to perform role migration.\r\n notice: i18n.translate('xpack.security...', {\r\n defaultMessage: \"Feature Alpha is deprecated, refer to {link}...\",\r\n values: { link: docLinks.links.security.deprecatedFeatureAlpha },\r\n })\r\n },\r\n // Feature id should stay unchanged, and it's not possible to reuse it.\r\n id: 'feature_alpha',\r\n name: 'Feature Alpha (DEPRECATED)',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['all'] },\r\n { feature: 'feature_gamma', privileges: ['all'] },\r\n ],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['read'] },\r\n { feature: 'feature_gamma', privileges: ['read'] },\r\n\t],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n\r\n</details>\r\n\r\n### Step 4: Adjust the code to rely only on new, non-deprecated features\r\n\r\nSpecial care should be taken if the replacement privileges cannot reuse\r\nthe API access tags from the deprecated privileges and introduce new\r\ntags that will be applied to the same API endpoints. In this case,\r\ndevelopers should replace the API access tags of the deprecated\r\nprivileges with the corresponding tags provided by the replacement\r\nprivileges. This is necessary because API endpoints can only be accessed\r\nif the user privileges cover all the tags listed in the API endpoint\r\ndefinition, and without these changes, existing roles referencing\r\ndeprecated privileges won’t be able to access those endpoints.\r\n\r\nThe UI capabilities are handled slightly differently because they are\r\nalways prefixed with the feature ID. When migrating to new features with\r\nnew IDs, the code that interacts with UI capabilities will be updated to\r\nuse these new feature IDs.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\n// BEFORE deprecation/migration\r\n// 1. Feature Alpha defition (not deprecated yet)\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_alpha',\r\n privileges: {\r\n all: {\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 2. Route protected by `all` privilege of the Feature Alpha\r\nrouter.post(\r\n { path: '/api/domain/my_api', options: { tags: ['access:api_all'] } },\r\n async (_context, request, response) => {}\r\n);\r\n\r\n// AFTER deprecation/migration\r\n// 1. Feature Alpha defition (deprecated, with updated API tags)\r\ndeps.features.registerKibanaFeature({\r\n deprecated: …,\r\n id: 'feature_alpha',\r\n privileges: {\r\n all: {\r\n api: ['api_all_v2'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['all'] },\r\n ],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 2. Feature Beta defition (new)\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_beta',\r\n privileges: {\r\n all: {\r\n api: ['api_all_v2'],\r\n … omitted for brevity …\r\n }\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 3. Route protected by `all` privilege of the Feature Alpha OR Feature Beta\r\nrouter.post(\r\n { path: '/api/domain/my_api', options: { tags: ['access:api_all_v2'] } },\r\n async (_context, request, response) => {}\r\n);\r\n\r\n----\r\n\r\n// ❌ Old client-side code (supports only deprecated privileges)\r\nif (capabilities.feature_alpha.ui_all) {\r\n … omitted for brevity …\r\n}\r\n\r\n// ✅ New client-side code (will work for **both** new and deprecated privileges)\r\nif (capabilities.feature_beta.ui_all) {\r\n … omitted for brevity …\r\n}\r\n```\r\n</details>\r\n\r\n## How to test\r\n\r\nThe code introduces a set of API integration tests that are designed to\r\nvalidate whether the privilege mapping between deprecated and\r\nreplacement privileges maintains backward compatibility.\r\n\r\nYou can run the test server with the following config to register a\r\nnumber of [example deprecated\r\nfeatures](https://github.com/elastic/kibana/pull/186800/files#diff-d887981d43bbe30cda039340b906b0fa7649ba80230be4de8eda326036f10f6fR20-R49)(`x-pack/test/security_api_integration/plugins/features_provider/server/index.ts`)\r\nand the features that replace them, to see the framework in action:\r\n\r\n```bash\r\nnode scripts/functional_tests_server.js --config x-pack/test/security_api_integration/features.config.ts\r\n```\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"cb2112cae51d5f69b9e47ebfde66cfacb2a6719b","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","release_note:skip","Feature:Security/Authorization","v9.0.0","backport:prev-minor","v8.16.0"],"number":186800,"url":"https://github.com/elastic/kibana/pull/186800","mergeCommit":{"message":"feat: allow plugins to deprecate and replace features and feature privileges (#186800)\n\n## Summary\r\n\r\nThis change is the implementation of the `Kibana Privilege Migrations`\r\nproposal/RFC and provides a framework that allows developers to replace\r\nan existing feature with a new one that has the desired configuration\r\nwhile teaching the platform how the privileges of the deprecated feature\r\ncan be represented by non-deprecated ones. This approach avoids\r\nintroducing breaking changes for users who still rely on the deprecated\r\nprivileges in their existing roles and any automation.\r\n\r\nAmong the use cases the framework is supposed to handle, the most common\r\nare the following:\r\n\r\n* Changing a feature ID from `Alpha` to `Beta`\r\n* Splitting a feature `Alpha` into two features, `Beta` and `Gamma`\r\n* Moving a capability between privileges within a feature (top-level or\r\nsub-feature)\r\n* Consolidating capabilities across independent features\r\n\r\n## Scope\r\n\r\nThis PR includes only the core functionality proposed in the RFC and\r\nmost of the necessary guardrails (tests, early validations, etc.) to\r\nhelp engineers start planning and implementing their migrations as soon\r\nas possible. The following functionality will be added in follow-ups or\r\nonce we collect enough feedback:\r\n\r\n* Telemetry\r\n* Developer documentation\r\n* UI enhancements (highlighting roles with deprecated privileges and\r\nmanual migration actions)\r\n\r\n## Framework\r\n\r\nThe steps below use a scenario where a feature `Alpha` should be split\r\ninto two other features `Beta` and `Gamma` as an example.\r\n\r\n### Step 1: Create new features with the desired privileges\r\n\r\nFirst of all, define new feature or features with the desired\r\nconfiguration as you'd do before. There are no constraints here.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_beta',\r\n name: 'Feature Beta',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_gamma',\r\n name: 'Feature Gamma',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_2'] },\r\n ui: ['ui_read'],\r\n // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n\r\n</details>\r\n\r\n### Step 2: Mark existing feature as deprecated\r\n\r\nOnce a feature is marked as deprecated, it should essentially be treated\r\nas frozen for backward compatibility reasons. Deprecated features will\r\nno longer be available through the Kibana role management UI and will be\r\nreplaced with non-deprecated privileges.\r\n\r\nDeprecated privileges will still be accepted if the role is created or\r\nupdated via the Kibana role management APIs to avoid disrupting existing\r\nuser automation.\r\n\r\nTo avoid breaking existing roles that reference privileges provided by\r\nthe deprecated features, Kibana will continue registering these\r\nprivileges as Elasticsearch application privileges.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n // This is a new `KibanaFeature` property available during feature registration.\r\n deprecated: {\r\n // User-facing justification for privilege deprecation that we can display\r\n // to the user when we ask them to perform role migration.\r\n notice: i18n.translate('xpack.security...', {\r\n defaultMessage: \"Feature Alpha is deprecated, refer to {link}...\",\r\n values: { link: docLinks.links.security.deprecatedFeatureAlpha },\r\n })\r\n },\r\n // Feature id should stay unchanged, and it's not possible to reuse it.\r\n id: 'feature_alpha',\r\n name: 'Feature Alpha (DEPRECATED)',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n</details>\r\n\r\n### Step 3: Map deprecated feature’s privileges to the privileges of the\r\nnon-deprecated features\r\n\r\nThe important requirement for a successful migration from a deprecated\r\nfeature to a new feature or features is that it should be possible to\r\nexpress **any combination** of the deprecated feature and sub-feature\r\nprivileges with the feature or sub-feature privileges of non-deprecated\r\nfeatures. This way, while editing a role with deprecated feature\r\nprivileges in the UI, the admin will be interacting with new privileges\r\nas if they were creating a new role from scratch, maintaining\r\nconsistency.\r\n\r\nThe relationship between the privileges of the deprecated feature and\r\nthe privileges of the features that are supposed to replace them is\r\nexpressed with a new `replacedBy` property available on the privileges\r\nof the deprecated feature.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n // This is a new `KibanaFeature` property available during feature registration.\r\n deprecated: {\r\n // User-facing justification for privilege deprecation that we can display\r\n // to the user when we ask them to perform role migration.\r\n notice: i18n.translate('xpack.security...', {\r\n defaultMessage: \"Feature Alpha is deprecated, refer to {link}...\",\r\n values: { link: docLinks.links.security.deprecatedFeatureAlpha },\r\n })\r\n },\r\n // Feature id should stay unchanged, and it's not possible to reuse it.\r\n id: 'feature_alpha',\r\n name: 'Feature Alpha (DEPRECATED)',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['all'] },\r\n { feature: 'feature_gamma', privileges: ['all'] },\r\n ],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['read'] },\r\n { feature: 'feature_gamma', privileges: ['read'] },\r\n\t],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n\r\n</details>\r\n\r\n### Step 4: Adjust the code to rely only on new, non-deprecated features\r\n\r\nSpecial care should be taken if the replacement privileges cannot reuse\r\nthe API access tags from the deprecated privileges and introduce new\r\ntags that will be applied to the same API endpoints. In this case,\r\ndevelopers should replace the API access tags of the deprecated\r\nprivileges with the corresponding tags provided by the replacement\r\nprivileges. This is necessary because API endpoints can only be accessed\r\nif the user privileges cover all the tags listed in the API endpoint\r\ndefinition, and without these changes, existing roles referencing\r\ndeprecated privileges won’t be able to access those endpoints.\r\n\r\nThe UI capabilities are handled slightly differently because they are\r\nalways prefixed with the feature ID. When migrating to new features with\r\nnew IDs, the code that interacts with UI capabilities will be updated to\r\nuse these new feature IDs.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\n// BEFORE deprecation/migration\r\n// 1. Feature Alpha defition (not deprecated yet)\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_alpha',\r\n privileges: {\r\n all: {\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 2. Route protected by `all` privilege of the Feature Alpha\r\nrouter.post(\r\n { path: '/api/domain/my_api', options: { tags: ['access:api_all'] } },\r\n async (_context, request, response) => {}\r\n);\r\n\r\n// AFTER deprecation/migration\r\n// 1. Feature Alpha defition (deprecated, with updated API tags)\r\ndeps.features.registerKibanaFeature({\r\n deprecated: …,\r\n id: 'feature_alpha',\r\n privileges: {\r\n all: {\r\n api: ['api_all_v2'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['all'] },\r\n ],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 2. Feature Beta defition (new)\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_beta',\r\n privileges: {\r\n all: {\r\n api: ['api_all_v2'],\r\n … omitted for brevity …\r\n }\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 3. Route protected by `all` privilege of the Feature Alpha OR Feature Beta\r\nrouter.post(\r\n { path: '/api/domain/my_api', options: { tags: ['access:api_all_v2'] } },\r\n async (_context, request, response) => {}\r\n);\r\n\r\n----\r\n\r\n// ❌ Old client-side code (supports only deprecated privileges)\r\nif (capabilities.feature_alpha.ui_all) {\r\n … omitted for brevity …\r\n}\r\n\r\n// ✅ New client-side code (will work for **both** new and deprecated privileges)\r\nif (capabilities.feature_beta.ui_all) {\r\n … omitted for brevity …\r\n}\r\n```\r\n</details>\r\n\r\n## How to test\r\n\r\nThe code introduces a set of API integration tests that are designed to\r\nvalidate whether the privilege mapping between deprecated and\r\nreplacement privileges maintains backward compatibility.\r\n\r\nYou can run the test server with the following config to register a\r\nnumber of [example deprecated\r\nfeatures](https://github.com/elastic/kibana/pull/186800/files#diff-d887981d43bbe30cda039340b906b0fa7649ba80230be4de8eda326036f10f6fR20-R49)(`x-pack/test/security_api_integration/plugins/features_provider/server/index.ts`)\r\nand the features that replace them, to see the framework in action:\r\n\r\n```bash\r\nnode scripts/functional_tests_server.js --config x-pack/test/security_api_integration/features.config.ts\r\n```\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"cb2112cae51d5f69b9e47ebfde66cfacb2a6719b"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/186800","number":186800,"mergeCommit":{"message":"feat: allow plugins to deprecate and replace features and feature privileges (#186800)\n\n## Summary\r\n\r\nThis change is the implementation of the `Kibana Privilege Migrations`\r\nproposal/RFC and provides a framework that allows developers to replace\r\nan existing feature with a new one that has the desired configuration\r\nwhile teaching the platform how the privileges of the deprecated feature\r\ncan be represented by non-deprecated ones. This approach avoids\r\nintroducing breaking changes for users who still rely on the deprecated\r\nprivileges in their existing roles and any automation.\r\n\r\nAmong the use cases the framework is supposed to handle, the most common\r\nare the following:\r\n\r\n* Changing a feature ID from `Alpha` to `Beta`\r\n* Splitting a feature `Alpha` into two features, `Beta` and `Gamma`\r\n* Moving a capability between privileges within a feature (top-level or\r\nsub-feature)\r\n* Consolidating capabilities across independent features\r\n\r\n## Scope\r\n\r\nThis PR includes only the core functionality proposed in the RFC and\r\nmost of the necessary guardrails (tests, early validations, etc.) to\r\nhelp engineers start planning and implementing their migrations as soon\r\nas possible. The following functionality will be added in follow-ups or\r\nonce we collect enough feedback:\r\n\r\n* Telemetry\r\n* Developer documentation\r\n* UI enhancements (highlighting roles with deprecated privileges and\r\nmanual migration actions)\r\n\r\n## Framework\r\n\r\nThe steps below use a scenario where a feature `Alpha` should be split\r\ninto two other features `Beta` and `Gamma` as an example.\r\n\r\n### Step 1: Create new features with the desired privileges\r\n\r\nFirst of all, define new feature or features with the desired\r\nconfiguration as you'd do before. There are no constraints here.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_beta',\r\n name: 'Feature Beta',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_gamma',\r\n name: 'Feature Gamma',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_2'] },\r\n ui: ['ui_read'],\r\n // Note that Feature Gamma, unlike Features Alpha and Beta doesn't provide any API access tags\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n\r\n</details>\r\n\r\n### Step 2: Mark existing feature as deprecated\r\n\r\nOnce a feature is marked as deprecated, it should essentially be treated\r\nas frozen for backward compatibility reasons. Deprecated features will\r\nno longer be available through the Kibana role management UI and will be\r\nreplaced with non-deprecated privileges.\r\n\r\nDeprecated privileges will still be accepted if the role is created or\r\nupdated via the Kibana role management APIs to avoid disrupting existing\r\nuser automation.\r\n\r\nTo avoid breaking existing roles that reference privileges provided by\r\nthe deprecated features, Kibana will continue registering these\r\nprivileges as Elasticsearch application privileges.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n // This is a new `KibanaFeature` property available during feature registration.\r\n deprecated: {\r\n // User-facing justification for privilege deprecation that we can display\r\n // to the user when we ask them to perform role migration.\r\n notice: i18n.translate('xpack.security...', {\r\n defaultMessage: \"Feature Alpha is deprecated, refer to {link}...\",\r\n values: { link: docLinks.links.security.deprecatedFeatureAlpha },\r\n })\r\n },\r\n // Feature id should stay unchanged, and it's not possible to reuse it.\r\n id: 'feature_alpha',\r\n name: 'Feature Alpha (DEPRECATED)',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n</details>\r\n\r\n### Step 3: Map deprecated feature’s privileges to the privileges of the\r\nnon-deprecated features\r\n\r\nThe important requirement for a successful migration from a deprecated\r\nfeature to a new feature or features is that it should be possible to\r\nexpress **any combination** of the deprecated feature and sub-feature\r\nprivileges with the feature or sub-feature privileges of non-deprecated\r\nfeatures. This way, while editing a role with deprecated feature\r\nprivileges in the UI, the admin will be interacting with new privileges\r\nas if they were creating a new role from scratch, maintaining\r\nconsistency.\r\n\r\nThe relationship between the privileges of the deprecated feature and\r\nthe privileges of the features that are supposed to replace them is\r\nexpressed with a new `replacedBy` property available on the privileges\r\nof the deprecated feature.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\ndeps.features.registerKibanaFeature({\r\n // This is a new `KibanaFeature` property available during feature registration.\r\n deprecated: {\r\n // User-facing justification for privilege deprecation that we can display\r\n // to the user when we ask them to perform role migration.\r\n notice: i18n.translate('xpack.security...', {\r\n defaultMessage: \"Feature Alpha is deprecated, refer to {link}...\",\r\n values: { link: docLinks.links.security.deprecatedFeatureAlpha },\r\n })\r\n },\r\n // Feature id should stay unchanged, and it's not possible to reuse it.\r\n id: 'feature_alpha',\r\n name: 'Feature Alpha (DEPRECATED)',\r\n privileges: {\r\n all: {\r\n savedObject: { all: ['saved_object_1', 'saved_object_2'], read: [] },\r\n ui: ['ui_all'],\r\n api: ['api_all'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['all'] },\r\n { feature: 'feature_gamma', privileges: ['all'] },\r\n ],\r\n … omitted for brevity …\r\n },\r\n read: {\r\n savedObject: { all: [], read: ['saved_object_1', 'saved_object_2'] },\r\n ui: ['ui_read'],\r\n api: ['api_read'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['read'] },\r\n { feature: 'feature_gamma', privileges: ['read'] },\r\n\t],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n```\r\n\r\n</details>\r\n\r\n### Step 4: Adjust the code to rely only on new, non-deprecated features\r\n\r\nSpecial care should be taken if the replacement privileges cannot reuse\r\nthe API access tags from the deprecated privileges and introduce new\r\ntags that will be applied to the same API endpoints. In this case,\r\ndevelopers should replace the API access tags of the deprecated\r\nprivileges with the corresponding tags provided by the replacement\r\nprivileges. This is necessary because API endpoints can only be accessed\r\nif the user privileges cover all the tags listed in the API endpoint\r\ndefinition, and without these changes, existing roles referencing\r\ndeprecated privileges won’t be able to access those endpoints.\r\n\r\nThe UI capabilities are handled slightly differently because they are\r\nalways prefixed with the feature ID. When migrating to new features with\r\nnew IDs, the code that interacts with UI capabilities will be updated to\r\nuse these new feature IDs.\r\n\r\n<details>\r\n\r\n<summary>Click to see the code</summary>\r\n\r\n```ts\r\n// BEFORE deprecation/migration\r\n// 1. Feature Alpha defition (not deprecated yet)\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_alpha',\r\n privileges: {\r\n all: {\r\n api: ['api_all'],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 2. Route protected by `all` privilege of the Feature Alpha\r\nrouter.post(\r\n { path: '/api/domain/my_api', options: { tags: ['access:api_all'] } },\r\n async (_context, request, response) => {}\r\n);\r\n\r\n// AFTER deprecation/migration\r\n// 1. Feature Alpha defition (deprecated, with updated API tags)\r\ndeps.features.registerKibanaFeature({\r\n deprecated: …,\r\n id: 'feature_alpha',\r\n privileges: {\r\n all: {\r\n api: ['api_all_v2'],\r\n replacedBy: [\r\n { feature: 'feature_beta', privileges: ['all'] },\r\n ],\r\n … omitted for brevity …\r\n },\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 2. Feature Beta defition (new)\r\ndeps.features.registerKibanaFeature({\r\n id: 'feature_beta',\r\n privileges: {\r\n all: {\r\n api: ['api_all_v2'],\r\n … omitted for brevity …\r\n }\r\n },\r\n … omitted for brevity …\r\n});\r\n\r\n// 3. Route protected by `all` privilege of the Feature Alpha OR Feature Beta\r\nrouter.post(\r\n { path: '/api/domain/my_api', options: { tags: ['access:api_all_v2'] } },\r\n async (_context, request, response) => {}\r\n);\r\n\r\n----\r\n\r\n// ❌ Old client-side code (supports only deprecated privileges)\r\nif (capabilities.feature_alpha.ui_all) {\r\n … omitted for brevity …\r\n}\r\n\r\n// ✅ New client-side code (will work for **both** new and deprecated privileges)\r\nif (capabilities.feature_beta.ui_all) {\r\n … omitted for brevity …\r\n}\r\n```\r\n</details>\r\n\r\n## How to test\r\n\r\nThe code introduces a set of API integration tests that are designed to\r\nvalidate whether the privilege mapping between deprecated and\r\nreplacement privileges maintains backward compatibility.\r\n\r\nYou can run the test server with the following config to register a\r\nnumber of [example deprecated\r\nfeatures](https://github.com/elastic/kibana/pull/186800/files#diff-d887981d43bbe30cda039340b906b0fa7649ba80230be4de8eda326036f10f6fR20-R49)(`x-pack/test/security_api_integration/plugins/features_provider/server/index.ts`)\r\nand the features that replace them, to see the framework in action:\r\n\r\n```bash\r\nnode scripts/functional_tests_server.js --config x-pack/test/security_api_integration/features.config.ts\r\n```\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"cb2112cae51d5f69b9e47ebfde66cfacb2a6719b"}},{"branch":"8.x","label":"v8.16.0","labelRegex":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT-->
This commit is contained in:
parent
abfed861e6
commit
d63d72664d
75 changed files with 3894 additions and 182 deletions
|
@ -315,6 +315,7 @@ enabled:
|
|||
- x-pack/test/security_api_integration/saml.http2.config.ts
|
||||
- x-pack/test/security_api_integration/saml_cloud.config.ts
|
||||
- x-pack/test/security_api_integration/chips.config.ts
|
||||
- x-pack/test/security_api_integration/features.config.ts
|
||||
- x-pack/test/security_api_integration/session_idle.config.ts
|
||||
- x-pack/test/security_api_integration/session_invalidate.config.ts
|
||||
- x-pack/test/security_api_integration/session_lifespan.config.ts
|
||||
|
|
|
@ -522,6 +522,7 @@
|
|||
"@kbn/feature-flags-example-plugin": "link:examples/feature_flags_example",
|
||||
"@kbn/feature-usage-test-plugin": "link:x-pack/test/plugin_api_integration/plugins/feature_usage_test",
|
||||
"@kbn/features-plugin": "link:x-pack/plugins/features",
|
||||
"@kbn/features-provider-plugin": "link:x-pack/test/security_api_integration/plugins/features_provider",
|
||||
"@kbn/fec-alerts-test-plugin": "link:x-pack/test/functional_execution_context/plugins/alerts",
|
||||
"@kbn/field-formats-example-plugin": "link:examples/field_formats_example",
|
||||
"@kbn/field-formats-plugin": "link:src/plugins/field_formats",
|
||||
|
@ -797,6 +798,7 @@
|
|||
"@kbn/searchprofiler-plugin": "link:x-pack/plugins/searchprofiler",
|
||||
"@kbn/security-api-key-management": "link:x-pack/packages/security/api_key_management",
|
||||
"@kbn/security-authorization-core": "link:x-pack/packages/security/authorization_core",
|
||||
"@kbn/security-authorization-core-common": "link:x-pack/packages/security/authorization_core_common",
|
||||
"@kbn/security-form-components": "link:x-pack/packages/security/form_components",
|
||||
"@kbn/security-hardening": "link:packages/kbn-security-hardening",
|
||||
"@kbn/security-plugin": "link:x-pack/plugins/security",
|
||||
|
|
|
@ -14,6 +14,28 @@ import { KbnClient } from '@kbn/test';
|
|||
export class Role {
|
||||
constructor(private log: ToolingLog, private kibanaServer: KbnClient) {}
|
||||
|
||||
public async get(
|
||||
name: string,
|
||||
{ replaceDeprecatedPrivileges = true }: { replaceDeprecatedPrivileges?: boolean } = {}
|
||||
) {
|
||||
this.log.debug(`retrieving role ${name}`);
|
||||
const { data, status, statusText } = await this.kibanaServer
|
||||
.request({
|
||||
path: `/api/security/role/${name}?replaceDeprecatedPrivileges=${replaceDeprecatedPrivileges}`,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch((e) => {
|
||||
throw new Error(util.inspect(e.axiosError.response, true));
|
||||
});
|
||||
if (status !== 200) {
|
||||
throw new Error(
|
||||
`Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}`
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public async create(name: string, role: any) {
|
||||
this.log.debug(`creating role ${name}`);
|
||||
const { data, status, statusText } = await this.kibanaServer.request({
|
||||
|
|
|
@ -926,6 +926,8 @@
|
|||
"@kbn/feature-usage-test-plugin/*": ["x-pack/test/plugin_api_integration/plugins/feature_usage_test/*"],
|
||||
"@kbn/features-plugin": ["x-pack/plugins/features"],
|
||||
"@kbn/features-plugin/*": ["x-pack/plugins/features/*"],
|
||||
"@kbn/features-provider-plugin": ["x-pack/test/security_api_integration/plugins/features_provider"],
|
||||
"@kbn/features-provider-plugin/*": ["x-pack/test/security_api_integration/plugins/features_provider/*"],
|
||||
"@kbn/fec-alerts-test-plugin": ["x-pack/test/functional_execution_context/plugins/alerts"],
|
||||
"@kbn/fec-alerts-test-plugin/*": ["x-pack/test/functional_execution_context/plugins/alerts/*"],
|
||||
"@kbn/field-formats-example-plugin": ["examples/field_formats_example"],
|
||||
|
@ -1550,6 +1552,8 @@
|
|||
"@kbn/security-api-key-management/*": ["x-pack/packages/security/api_key_management/*"],
|
||||
"@kbn/security-authorization-core": ["x-pack/packages/security/authorization_core"],
|
||||
"@kbn/security-authorization-core/*": ["x-pack/packages/security/authorization_core/*"],
|
||||
"@kbn/security-authorization-core-common": ["x-pack/packages/security/authorization_core_common"],
|
||||
"@kbn/security-authorization-core-common/*": ["x-pack/packages/security/authorization_core_common/*"],
|
||||
"@kbn/security-form-components": ["x-pack/packages/security/form_components"],
|
||||
"@kbn/security-form-components/*": ["x-pack/packages/security/form_components/*"],
|
||||
"@kbn/security-hardening": ["packages/kbn-security-hardening"],
|
||||
|
|
|
@ -6,10 +6,5 @@
|
|||
*/
|
||||
|
||||
export { Actions } from './src/actions';
|
||||
export { privilegesFactory } from './src/privileges';
|
||||
export type {
|
||||
CasesSupportedOperations,
|
||||
PrivilegesService,
|
||||
RawKibanaPrivileges,
|
||||
RawKibanaFeaturePrivileges,
|
||||
} from './src/privileges';
|
||||
export { privilegesFactory, getReplacedByForPrivilege } from './src/privileges';
|
||||
export type { CasesSupportedOperations, PrivilegesService } from './src/privileges';
|
||||
|
|
|
@ -51,3 +51,21 @@ describe('#get', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('#isValid', () => {
|
||||
const alertingActions = new AlertingActions();
|
||||
expect(alertingActions.isValid('alerting:foo-ruleType/consumer/alertingType/bar-operation')).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(
|
||||
alertingActions.isValid('api:alerting:foo-ruleType/consumer/alertingType/bar-operation')
|
||||
).toBe(false);
|
||||
expect(alertingActions.isValid('api:foo-ruleType/consumer/alertingType/bar-operation')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(alertingActions.isValid('alerting_foo-ruleType/consumer/alertingType/bar-operation')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
|
|
@ -40,4 +40,12 @@ export class AlertingActions implements AlertingActionsType {
|
|||
|
||||
return `${this.prefix}${ruleTypeId}/${consumer}/${alertingEntity}/${operation}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the action is a valid alerting action.
|
||||
* @param action The action string to check.
|
||||
*/
|
||||
public isValid(action: string) {
|
||||
return action.startsWith(this.prefix);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,3 +32,15 @@ describe('#get', () => {
|
|||
expect(uiActions.get('foo', 'fooCapability', 'subFoo')).toBe('ui:foo/fooCapability/subFoo');
|
||||
});
|
||||
});
|
||||
|
||||
test('#isValid', () => {
|
||||
const uiActions = new UIActions();
|
||||
expect(uiActions.isValid('ui:alpha')).toBe(true);
|
||||
expect(uiActions.isValid('ui:beta')).toBe(true);
|
||||
|
||||
expect(uiActions.isValid('api:alpha')).toBe(false);
|
||||
expect(uiActions.isValid('api:beta')).toBe(false);
|
||||
|
||||
expect(uiActions.isValid('ui_alpha')).toBe(false);
|
||||
expect(uiActions.isValid('ui_beta')).toBe(false);
|
||||
});
|
||||
|
|
|
@ -40,4 +40,12 @@ export class UIActions implements UIActionsType {
|
|||
|
||||
return `${this.prefix}${featureId}/${uiCapabilityParts.join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the action is a valid UI action.
|
||||
* @param action The action string to check.
|
||||
*/
|
||||
public isValid(action: string) {
|
||||
return action.startsWith(this.prefix);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,4 @@
|
|||
|
||||
export type { PrivilegesService } from './privileges';
|
||||
export type { CasesSupportedOperations } from './feature_privilege_builder';
|
||||
export { privilegesFactory } from './privileges';
|
||||
export type { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
|
||||
export { privilegesFactory, getReplacedByForPrivilege } from './privileges';
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { KibanaFeature } from '@kbn/features-plugin/server';
|
||||
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
|
||||
|
||||
import { privilegesFactory } from './privileges';
|
||||
import { getReplacedByForPrivilege, privilegesFactory } from './privileges';
|
||||
import { licenseMock } from '../__fixtures__/licensing.mock';
|
||||
import { Actions } from '../actions';
|
||||
|
||||
|
@ -472,6 +472,184 @@ describe('features', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('actions should respect `replacedBy` specified by the deprecated privileges', () => {
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
deprecated: { notice: 'It is deprecated, sorry.' },
|
||||
id: 'alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
alerting: ['rule-type-1'],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-alpha-all-so'],
|
||||
read: ['all-alpha-read-so'],
|
||||
},
|
||||
ui: ['all-alpha-ui'],
|
||||
app: ['all-alpha-app'],
|
||||
api: ['all-alpha-api'],
|
||||
alerting: { rule: { all: ['rule-type-1'] } },
|
||||
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-alpha-all-so'],
|
||||
read: ['read-alpha-read-so'],
|
||||
},
|
||||
ui: ['read-alpha-ui'],
|
||||
app: ['read-alpha-app'],
|
||||
api: ['read-alpha-api'],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'beta', privileges: ['read'] }],
|
||||
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
new KibanaFeature({
|
||||
id: 'beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
alerting: ['rule-type-1'],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-beta-all-so'],
|
||||
read: ['all-beta-read-so'],
|
||||
},
|
||||
ui: ['all-beta-ui'],
|
||||
app: ['all-beta-app'],
|
||||
api: ['all-beta-api'],
|
||||
alerting: { rule: { all: ['rule-type-1'] } },
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-beta-all-so'],
|
||||
read: ['read-beta-read-so'],
|
||||
},
|
||||
ui: ['read-beta-ui'],
|
||||
app: ['read-beta-app'],
|
||||
api: ['read-beta-api'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const mockFeaturesPlugin = featuresPluginMock.createSetup();
|
||||
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
|
||||
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
|
||||
|
||||
const alertingOperations = [
|
||||
...[
|
||||
'get',
|
||||
'getRuleState',
|
||||
'getAlertSummary',
|
||||
'getExecutionLog',
|
||||
'getActionErrorLog',
|
||||
'find',
|
||||
'getRuleExecutionKPI',
|
||||
'getBackfill',
|
||||
'findBackfill',
|
||||
],
|
||||
...[
|
||||
'create',
|
||||
'delete',
|
||||
'update',
|
||||
'updateApiKey',
|
||||
'enable',
|
||||
'disable',
|
||||
'muteAll',
|
||||
'unmuteAll',
|
||||
'muteAlert',
|
||||
'unmuteAlert',
|
||||
'snooze',
|
||||
'bulkEdit',
|
||||
'bulkDelete',
|
||||
'bulkEnable',
|
||||
'bulkDisable',
|
||||
'unsnooze',
|
||||
'runSoon',
|
||||
'scheduleBackfill',
|
||||
'deleteBackfill',
|
||||
],
|
||||
];
|
||||
|
||||
const expectedAllPrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('all-alpha-api'),
|
||||
actions.app.get('all-alpha-app'),
|
||||
actions.ui.get('navLinks', 'all-alpha-app'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'all-alpha-ui'),
|
||||
...alertingOperations.map((operation) =>
|
||||
actions.alerting.get('rule-type-1', 'alpha', 'rule', operation)
|
||||
),
|
||||
// To maintain compatibility with the new UI capabilities and new alerting entities that are
|
||||
// feature specific: all.replacedBy: [{ feature: 'beta', privileges: ['all'] }]
|
||||
actions.ui.get('navLinks', 'all-beta-app'),
|
||||
actions.ui.get('beta', 'all-beta-ui'),
|
||||
...alertingOperations.map((operation) =>
|
||||
actions.alerting.get('rule-type-1', 'beta', 'rule', operation)
|
||||
),
|
||||
];
|
||||
|
||||
const expectedReadPrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('read-alpha-api'),
|
||||
actions.app.get('read-alpha-app'),
|
||||
actions.ui.get('navLinks', 'read-alpha-app'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'read-alpha-ui'),
|
||||
// To maintain compatibility with the new UI capabilities that are feature specific
|
||||
// read.replacedBy: [{ feature: 'beta', privileges: ['read'] }]
|
||||
actions.ui.get('navLinks', 'read-beta-app'),
|
||||
actions.ui.get('beta', 'read-beta-ui'),
|
||||
];
|
||||
|
||||
const actual = privileges.get();
|
||||
expect(actual).toHaveProperty('features.alpha', {
|
||||
all: [...expectedAllPrivileges],
|
||||
read: [...expectedReadPrivileges],
|
||||
minimal_all: [...expectedAllPrivileges],
|
||||
minimal_read: [...expectedReadPrivileges],
|
||||
});
|
||||
});
|
||||
|
||||
test(`features with no privileges aren't listed`, () => {
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
|
@ -3510,4 +3688,360 @@ describe('subFeatures', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('actions should respect `replacedBy` specified by the deprecated sub-feature privileges', () => {
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
deprecated: { notice: 'It is deprecated, sorry.' },
|
||||
id: 'alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-alpha-all-so'],
|
||||
read: ['all-alpha-read-so'],
|
||||
},
|
||||
ui: ['all-alpha-ui'],
|
||||
app: ['all-alpha-app'],
|
||||
api: ['all-alpha-api'],
|
||||
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-alpha-all-so'],
|
||||
read: ['read-alpha-read-so'],
|
||||
},
|
||||
ui: ['read-alpha-ui'],
|
||||
app: ['read-alpha-app'],
|
||||
api: ['read-alpha-api'],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }],
|
||||
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_alpha',
|
||||
name: 'Sub Feature Alpha',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-alpha-all-so'],
|
||||
read: ['sub-alpha-read-so'],
|
||||
},
|
||||
ui: ['sub-alpha-ui'],
|
||||
app: ['sub-alpha-app'],
|
||||
api: ['sub-alpha-api'],
|
||||
replacedBy: [
|
||||
{ feature: 'beta', privileges: ['minimal_read'] },
|
||||
{ feature: 'beta', privileges: ['sub_beta'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
new KibanaFeature({
|
||||
id: 'beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-beta-all-so'],
|
||||
read: ['all-beta-read-so'],
|
||||
},
|
||||
ui: ['all-beta-ui'],
|
||||
app: ['all-beta-app'],
|
||||
api: ['all-beta-api'],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-beta-all-so'],
|
||||
read: ['read-beta-read-so'],
|
||||
},
|
||||
ui: ['read-beta-ui'],
|
||||
app: ['read-beta-app'],
|
||||
api: ['read-beta-api'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-beta',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_beta',
|
||||
name: 'Sub Feature Beta',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-beta-all-so'],
|
||||
read: ['sub-beta-read-so'],
|
||||
},
|
||||
ui: ['sub-beta-ui'],
|
||||
app: ['sub-beta-app'],
|
||||
api: ['sub-beta-api'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const mockFeaturesPlugin = featuresPluginMock.createSetup();
|
||||
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
|
||||
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceGold);
|
||||
|
||||
const expectedAllPrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('all-alpha-api'),
|
||||
actions.api.get('sub-alpha-api'),
|
||||
actions.app.get('all-alpha-app'),
|
||||
actions.app.get('sub-alpha-app'),
|
||||
actions.ui.get('navLinks', 'all-alpha-app'),
|
||||
actions.ui.get('navLinks', 'sub-alpha-app'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'all-alpha-ui'),
|
||||
actions.ui.get('alpha', 'sub-alpha-ui'),
|
||||
// To maintain compatibility with the new UI capabilities that are feature specific:
|
||||
// all.replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
actions.ui.get('navLinks', 'all-beta-app'),
|
||||
actions.ui.get('navLinks', 'sub-beta-app'),
|
||||
actions.ui.get('beta', 'all-beta-ui'),
|
||||
actions.ui.get('beta', 'sub-beta-ui'),
|
||||
];
|
||||
|
||||
const expectedMinimalAllPrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('all-alpha-api'),
|
||||
actions.app.get('all-alpha-app'),
|
||||
actions.ui.get('navLinks', 'all-alpha-app'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('all-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'all-alpha-ui'),
|
||||
// To maintain compatibility with the new UI capabilities that are feature specific.
|
||||
// Actions from the beta feature top-level and sub-feature privileges are included because
|
||||
// used simple `replacedBy` format:
|
||||
// all.replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
actions.ui.get('navLinks', 'all-beta-app'),
|
||||
actions.ui.get('navLinks', 'sub-beta-app'),
|
||||
actions.ui.get('beta', 'all-beta-ui'),
|
||||
actions.ui.get('beta', 'sub-beta-ui'),
|
||||
];
|
||||
|
||||
const expectedReadPrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('read-alpha-api'),
|
||||
actions.app.get('read-alpha-app'),
|
||||
actions.ui.get('navLinks', 'read-alpha-app'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'read-alpha-ui'),
|
||||
// To maintain compatibility with the new UI capabilities that are feature specific:
|
||||
// read.replacedBy: {
|
||||
// default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }]
|
||||
// },
|
||||
actions.ui.get('navLinks', 'read-beta-app'),
|
||||
actions.ui.get('beta', 'read-beta-ui'),
|
||||
actions.ui.get('navLinks', 'sub-beta-app'),
|
||||
actions.ui.get('beta', 'sub-beta-ui'),
|
||||
];
|
||||
|
||||
const expectedMinimalReadPrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('read-alpha-api'),
|
||||
actions.app.get('read-alpha-app'),
|
||||
actions.ui.get('navLinks', 'read-alpha-app'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('read-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'read-alpha-ui'),
|
||||
// To maintain compatibility with the new UI capabilities that are feature specific:
|
||||
// read.replacedBy: {
|
||||
// minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
// },
|
||||
actions.ui.get('navLinks', 'read-beta-app'),
|
||||
actions.ui.get('beta', 'read-beta-ui'),
|
||||
];
|
||||
|
||||
const expectedSubFeaturePrivileges = [
|
||||
actions.login,
|
||||
actions.api.get('sub-alpha-api'),
|
||||
actions.app.get('sub-alpha-app'),
|
||||
actions.ui.get('navLinks', 'sub-alpha-app'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_get'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'get'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'find'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'close_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'create'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_create'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'update'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_update'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'delete'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'bulk_delete'),
|
||||
actions.savedObject.get('sub-alpha-all-so', 'share_to_space'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'bulk_get'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'get'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'find'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'open_point_in_time'),
|
||||
actions.savedObject.get('sub-alpha-read-so', 'close_point_in_time'),
|
||||
actions.ui.get('alpha', 'sub-alpha-ui'),
|
||||
// To maintain compatibility with the new UI capabilities that are feature specific:
|
||||
// sub_alpha.replacedBy: [
|
||||
// { feature: 'beta', privileges: ['minimal_read'] },
|
||||
// { feature: 'beta', privileges: ['sub_beta'] },
|
||||
// ],
|
||||
actions.ui.get('navLinks', 'read-beta-app'),
|
||||
actions.ui.get('beta', 'read-beta-ui'),
|
||||
actions.ui.get('navLinks', 'sub-beta-app'),
|
||||
actions.ui.get('beta', 'sub-beta-ui'),
|
||||
];
|
||||
|
||||
const actual = privileges.get();
|
||||
expect(actual).toHaveProperty('features.alpha', {
|
||||
all: expectedAllPrivileges,
|
||||
read: expectedReadPrivileges,
|
||||
minimal_all: expectedMinimalAllPrivileges,
|
||||
minimal_read: expectedMinimalReadPrivileges,
|
||||
sub_alpha: expectedSubFeaturePrivileges,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getReplacedByForPrivilege', () => {
|
||||
test('correctly gets `replacedBy` with simple format', () => {
|
||||
const basePrivilege = { savedObject: { all: [], read: [] }, ui: [] };
|
||||
expect(getReplacedByForPrivilege('all', basePrivilege)).toBeUndefined();
|
||||
expect(getReplacedByForPrivilege('minimal_all', basePrivilege)).toBeUndefined();
|
||||
|
||||
const privilegeWithReplacedBy = {
|
||||
...basePrivilege,
|
||||
replacedBy: [{ feature: 'alpha', privileges: ['all', 'read'] }],
|
||||
};
|
||||
expect(getReplacedByForPrivilege('all', privilegeWithReplacedBy)).toEqual([
|
||||
{ feature: 'alpha', privileges: ['all', 'read'] },
|
||||
]);
|
||||
expect(getReplacedByForPrivilege('minimal_all', privilegeWithReplacedBy)).toEqual([
|
||||
{ feature: 'alpha', privileges: ['all', 'read'] },
|
||||
]);
|
||||
expect(getReplacedByForPrivilege('custom', privilegeWithReplacedBy)).toEqual([
|
||||
{ feature: 'alpha', privileges: ['all', 'read'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('correctly gets `replacedBy` with extended format', () => {
|
||||
const basePrivilege = { savedObject: { all: [], read: [] }, ui: [] };
|
||||
expect(getReplacedByForPrivilege('all', basePrivilege)).toBeUndefined();
|
||||
expect(getReplacedByForPrivilege('minimal_all', basePrivilege)).toBeUndefined();
|
||||
|
||||
const privilegeWithReplacedBy = {
|
||||
...basePrivilege,
|
||||
replacedBy: {
|
||||
default: [{ feature: 'alpha', privileges: ['all', 'read', 'custom'] }],
|
||||
minimal: [{ feature: 'alpha', privileges: ['minimal_all'] }],
|
||||
},
|
||||
};
|
||||
expect(getReplacedByForPrivilege('all', privilegeWithReplacedBy)).toEqual([
|
||||
{ feature: 'alpha', privileges: ['all', 'read', 'custom'] },
|
||||
]);
|
||||
expect(getReplacedByForPrivilege('custom', privilegeWithReplacedBy)).toEqual([
|
||||
{ feature: 'alpha', privileges: ['all', 'read', 'custom'] },
|
||||
]);
|
||||
expect(getReplacedByForPrivilege('minimal_all', privilegeWithReplacedBy)).toEqual([
|
||||
{ feature: 'alpha', privileges: ['minimal_all'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,10 +12,13 @@ import type {
|
|||
FeatureKibanaPrivilegesReference,
|
||||
} from '@kbn/features-plugin/common';
|
||||
import type { FeaturesPluginSetup, KibanaFeature } from '@kbn/features-plugin/server';
|
||||
import type { SecurityLicense } from '@kbn/security-plugin-types-common';
|
||||
import {
|
||||
getMinimalPrivilegeId,
|
||||
isMinimalPrivilegeId,
|
||||
} from '@kbn/security-authorization-core-common';
|
||||
import type { RawKibanaPrivileges, SecurityLicense } from '@kbn/security-plugin-types-common';
|
||||
|
||||
import { featurePrivilegeBuilderFactory } from './feature_privilege_builder';
|
||||
import type { RawKibanaPrivileges } from './raw_kibana_privileges';
|
||||
import type { Actions } from '../actions';
|
||||
|
||||
export interface PrivilegesService {
|
||||
|
@ -63,26 +66,46 @@ export function privilegesFactory(
|
|||
|
||||
// Remember privilege as composable to update it later, once actions for all referenced privileges are also
|
||||
// calculated and registered.
|
||||
const composableFeaturePrivileges: Array<{
|
||||
const composablePrivileges: Array<{
|
||||
featureId: string;
|
||||
privilegeId: string;
|
||||
references: readonly FeatureKibanaPrivilegesReference[];
|
||||
excludeFromBasePrivileges?: boolean;
|
||||
composedOf: readonly FeatureKibanaPrivilegesReference[];
|
||||
actionsFilter?: (action: string) => boolean;
|
||||
}> = [];
|
||||
const tryStoreComposableFeature = (
|
||||
const tryStoreComposablePrivilege = (
|
||||
feature: KibanaFeature,
|
||||
privilegeId: string,
|
||||
privilege: FeatureKibanaPrivileges
|
||||
) => {
|
||||
// If privilege is configured with `composedOf` it should be complemented with **all**
|
||||
// actions from referenced privileges.
|
||||
if (privilege.composedOf) {
|
||||
composableFeaturePrivileges.push({
|
||||
composablePrivileges.push({
|
||||
featureId: feature.id,
|
||||
privilegeId,
|
||||
composedOf: privilege.composedOf,
|
||||
references: privilege.composedOf,
|
||||
excludeFromBasePrivileges:
|
||||
feature.excludeFromBasePrivileges || privilege.excludeFromBasePrivileges,
|
||||
});
|
||||
}
|
||||
|
||||
// If a privilege is configured with `replacedBy`, it's part of the deprecated feature and
|
||||
// should be complemented with the subset of actions from the referenced privileges to
|
||||
// maintain backward compatibility. Namely, deprecated privileges should grant the same UI
|
||||
// capabilities and alerting actions as the privileges that replace them, so that the
|
||||
// client-side code can safely use only non-deprecated UI capabilities and users can still
|
||||
// access previously created alerting rules and alerts.
|
||||
const replacedBy = getReplacedByForPrivilege(privilegeId, privilege);
|
||||
if (replacedBy) {
|
||||
composablePrivileges.push({
|
||||
featureId: feature.id,
|
||||
privilegeId,
|
||||
references: replacedBy,
|
||||
actionsFilter: (action) =>
|
||||
actions.ui.isValid(action) || actions.alerting.isValid(action),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hiddenFeatures = new Set<string>();
|
||||
|
@ -99,20 +122,20 @@ export function privilegesFactory(
|
|||
...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)),
|
||||
];
|
||||
|
||||
tryStoreComposableFeature(feature, fullPrivilegeId, featurePrivilege.privilege);
|
||||
tryStoreComposablePrivilege(feature, fullPrivilegeId, featurePrivilege.privilege);
|
||||
}
|
||||
|
||||
for (const featurePrivilege of featuresService.featurePrivilegeIterator(feature, {
|
||||
augmentWithSubFeaturePrivileges: false,
|
||||
licenseHasAtLeast,
|
||||
})) {
|
||||
const minimalPrivilegeId = `minimal_${featurePrivilege.privilegeId}`;
|
||||
const minimalPrivilegeId = getMinimalPrivilegeId(featurePrivilege.privilegeId);
|
||||
featurePrivileges[feature.id][minimalPrivilegeId] = [
|
||||
actions.login,
|
||||
...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)),
|
||||
];
|
||||
|
||||
tryStoreComposableFeature(feature, minimalPrivilegeId, featurePrivilege.privilege);
|
||||
tryStoreComposablePrivilege(feature, minimalPrivilegeId, featurePrivilege.privilege);
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -127,6 +150,8 @@ export function privilegesFactory(
|
|||
actions.login,
|
||||
...uniq(featurePrivilegeBuilder.getActions(subFeaturePrivilege, feature)),
|
||||
];
|
||||
|
||||
tryStoreComposablePrivilege(feature, subFeaturePrivilege.id, subFeaturePrivilege);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -141,11 +166,14 @@ export function privilegesFactory(
|
|||
// another feature. This could potentially enable functionality in a license lower than originally intended. It
|
||||
// might or might not be desired, but we're accepting this for now, as every attempt to compose a feature
|
||||
// undergoes a stringent review process.
|
||||
for (const composableFeature of composableFeaturePrivileges) {
|
||||
const composedActions = composableFeature.composedOf.flatMap((privilegeReference) =>
|
||||
privilegeReference.privileges.flatMap(
|
||||
(privilege) => featurePrivileges[privilegeReference.feature][privilege]
|
||||
)
|
||||
for (const composableFeature of composablePrivileges) {
|
||||
const composedActions = composableFeature.references.flatMap((privilegeReference) =>
|
||||
privilegeReference.privileges.flatMap((privilege) => {
|
||||
const privilegeActions = featurePrivileges[privilegeReference.feature][privilege] ?? [];
|
||||
return composableFeature.actionsFilter
|
||||
? privilegeActions.filter(composableFeature.actionsFilter)
|
||||
: privilegeActions;
|
||||
})
|
||||
);
|
||||
featurePrivileges[composableFeature.featureId][composableFeature.privilegeId] = [
|
||||
...new Set(
|
||||
|
@ -220,3 +248,27 @@ export function privilegesFactory(
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of privileges that replace the given privilege, if any. Works for both top-level
|
||||
* and sub-feature privileges.
|
||||
* @param privilegeId The ID of the privilege to get replacements for.
|
||||
* @param privilege The privilege definition to get replacements for.
|
||||
*/
|
||||
export function getReplacedByForPrivilege(
|
||||
privilegeId: string,
|
||||
privilege: FeatureKibanaPrivileges
|
||||
): readonly FeatureKibanaPrivilegesReference[] | undefined {
|
||||
const replacedBy = privilege.replacedBy;
|
||||
if (!replacedBy) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a privilege of the deprecated feature explicitly defines a replacement for minimal privileges, use it.
|
||||
// Otherwise, use the default replacement for all cases.
|
||||
return 'minimal' in replacedBy
|
||||
? isMinimalPrivilegeId(privilegeId)
|
||||
? replacedBy.minimal
|
||||
: replacedBy.default
|
||||
: replacedBy;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/features-plugin",
|
||||
"@kbn/security-authorization-core-common",
|
||||
"@kbn/security-plugin-types-common",
|
||||
"@kbn/security-plugin-types-server",
|
||||
"@kbn/licensing-plugin",
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/security-authorization-core-common
|
||||
|
||||
Contains core authorization logic (shared between server and browser)
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { isMinimalPrivilegeId, getMinimalPrivilegeId } from './src/privileges';
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/x-pack/packages/security/authorization_core_common',
|
||||
coverageReporters: ['text', 'html'],
|
||||
collectCoverageFrom: [
|
||||
'<rootDir>/x-pack/packages/security/authorization_core_common/**/*.{ts,tsx}',
|
||||
],
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/x-pack/packages/security/authorization_core_common'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/security-authorization-core-common",
|
||||
"owner": "@elastic/kibana-security"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/security-authorization-core-common",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0"
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { isMinimalPrivilegeId, getMinimalPrivilegeId } from './minimal_privileges';
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 { getMinimalPrivilegeId, isMinimalPrivilegeId } from '../..';
|
||||
|
||||
describe('Minimal privileges', () => {
|
||||
it('#isMinimalPrivilegeId correctly detects minimal privileges', () => {
|
||||
expect(isMinimalPrivilegeId('minimal_all')).toBe(true);
|
||||
expect(isMinimalPrivilegeId('minimal_read')).toBe(true);
|
||||
|
||||
for (const privilege of ['all', 'read', 'none', 'custom', 'minimal_custom', 'minimal_none']) {
|
||||
expect(isMinimalPrivilegeId(privilege)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('#getMinimalPrivilegeId correctly constructs minimal privilege ID', () => {
|
||||
expect(getMinimalPrivilegeId('all')).toBe('minimal_all');
|
||||
expect(getMinimalPrivilegeId('minimal_all')).toBe('minimal_all');
|
||||
|
||||
expect(getMinimalPrivilegeId('read')).toBe('minimal_read');
|
||||
expect(getMinimalPrivilegeId('minimal_read')).toBe('minimal_read');
|
||||
|
||||
expect(() => getMinimalPrivilegeId('none')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"none\\" was provided."`
|
||||
);
|
||||
expect(() => getMinimalPrivilegeId('custom')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"custom\\" was provided."`
|
||||
);
|
||||
expect(() => getMinimalPrivilegeId('minimal_none')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"minimal_none\\" was provided."`
|
||||
);
|
||||
expect(() => getMinimalPrivilegeId('minimal_custom')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"minimal_custom\\" was provided."`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal privileges only exist for top-level privileges, as "minimal" means a privilege without
|
||||
* any associated sub-feature privileges. Currently, sub-feature privileges cannot include or be
|
||||
* associated with other sub-feature privileges. We use "minimal" privileges under the hood when
|
||||
* admins customize sub-feature privileges for a given top-level privilege. We have only
|
||||
* `minimal_all` and `minimal_read` minimal privileges.
|
||||
*
|
||||
* For example, let’s assume we have a feature Alpha with `All` and `Read` top-level privileges, and
|
||||
* `Sub-alpha-1` and `Sub-alpha-2` sub-feature privileges, which are **by default included** in the
|
||||
* `All` top-level privilege. When an admin toggles the `All` privilege for feature Alpha and
|
||||
* doesn’t change anything else, the resulting role will only have the `feature-alpha.all`
|
||||
* privilege, which assumes/includes both `sub-alpha-1` and `sub-alpha-2`. However, if the admin
|
||||
* decides to customize sub-feature privileges and toggles off `Sub-alpha-2`, the resulting role
|
||||
* will include `feature-alpha.minimal_all` and `feature-alpha.sub-alpha-1` thus excluding
|
||||
* `feature-alpha.sub-alpha-2` that's included in `feature-alpha.all`, but not in
|
||||
* `feature-alpha.minimal_all`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns true if the given privilege ID is a minimal feature privilege.
|
||||
* @param privilegeId The privilege ID to check.
|
||||
*/
|
||||
export function isMinimalPrivilegeId(privilegeId: string) {
|
||||
return privilegeId === 'minimal_all' || privilegeId === 'minimal_read';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minimal privilege ID for the given privilege ID.
|
||||
* @param privilegeId The privilege ID to get the minimal privilege ID for. Only `all` and `read`
|
||||
* privileges have "minimal" equivalents.
|
||||
*/
|
||||
export function getMinimalPrivilegeId(privilegeId: string) {
|
||||
if (isMinimalPrivilegeId(privilegeId)) {
|
||||
return privilegeId;
|
||||
}
|
||||
|
||||
if (privilegeId !== 'read' && privilegeId !== 'all') {
|
||||
throw new Error(
|
||||
`Minimal privileges are only available for "read" and "all" privileges, but "${privilegeId}" was provided.`
|
||||
);
|
||||
}
|
||||
|
||||
return `minimal_${privilegeId}`;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node", "react"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -18,6 +18,8 @@ export type {
|
|||
RoleRemoteIndexPrivilege,
|
||||
RoleRemoteClusterPrivilege,
|
||||
FeaturesPrivileges,
|
||||
RawKibanaFeaturePrivileges,
|
||||
RawKibanaPrivileges,
|
||||
} from './src/authorization';
|
||||
export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './src/licensing';
|
||||
export type {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
export type { FeaturesPrivileges } from './features_privileges';
|
||||
export type { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from './raw_kibana_privileges';
|
||||
export type {
|
||||
Role,
|
||||
RoleKibanaPrivilege,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { RawKibanaPrivileges } from '@kbn/security-authorization-core';
|
||||
import type { RawKibanaPrivileges } from '@kbn/security-plugin-types-common';
|
||||
|
||||
export interface PrivilegesAPIClientGetAllArgs {
|
||||
includeActions: boolean;
|
||||
|
|
|
@ -14,6 +14,5 @@
|
|||
"@kbn/core-user-profile-common",
|
||||
"@kbn/security-plugin-types-common",
|
||||
"@kbn/core-security-common",
|
||||
"@kbn/security-authorization-core"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -6,8 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import type { RawKibanaPrivileges } from '@kbn/security-authorization-core';
|
||||
import type { RoleKibanaPrivilege } from '@kbn/security-plugin-types-common';
|
||||
import type { RawKibanaPrivileges, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common';
|
||||
|
||||
import { KibanaPrivilege } from './kibana_privilege';
|
||||
import { PrivilegeCollection } from './privilege_collection';
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { FeatureKibanaPrivileges } from '@kbn/features-plugin/public';
|
||||
import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common';
|
||||
|
||||
import { KibanaPrivilege } from './kibana_privilege';
|
||||
|
||||
|
@ -18,15 +19,8 @@ export class PrimaryFeaturePrivilege extends KibanaPrivilege {
|
|||
super(id, actions);
|
||||
}
|
||||
|
||||
public isMinimalFeaturePrivilege() {
|
||||
return this.id.startsWith('minimal_');
|
||||
}
|
||||
|
||||
public getMinimalPrivilegeId() {
|
||||
if (this.isMinimalFeaturePrivilege()) {
|
||||
return this.id;
|
||||
}
|
||||
return `minimal_${this.id}`;
|
||||
return getMinimalPrivilegeId(this.id);
|
||||
}
|
||||
|
||||
public get requireAllSpaces() {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
|
||||
import { KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common';
|
||||
|
||||
import { PrimaryFeaturePrivilege } from './primary_feature_privilege';
|
||||
import { SecuredSubFeature } from './secured_sub_feature';
|
||||
|
@ -31,8 +32,14 @@ export class SecuredFeature extends KibanaFeature {
|
|||
);
|
||||
|
||||
this.minimalPrimaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map(
|
||||
([id, privilege]) =>
|
||||
new PrimaryFeaturePrivilege(`minimal_${id}`, privilege, actionMapping[`minimal_${id}`])
|
||||
([id, privilege]) => {
|
||||
const minimalPrivilegeId = getMinimalPrivilegeId(id);
|
||||
return new PrimaryFeaturePrivilege(
|
||||
minimalPrivilegeId,
|
||||
privilege,
|
||||
actionMapping[minimalPrivilegeId]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
this.securedSubFeatures =
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"@kbn/features-plugin",
|
||||
"@kbn/security-plugin-types-common",
|
||||
"@kbn/security-authorization-core",
|
||||
"@kbn/security-authorization-core-common",
|
||||
"@kbn/licensing-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -272,4 +272,17 @@ export interface FeatureKibanaPrivileges {
|
|||
* grant. This property can only be set in the feature configuration overrides.
|
||||
*/
|
||||
composedOf?: readonly FeatureKibanaPrivilegesReference[];
|
||||
|
||||
/**
|
||||
* An optional list of other registered feature or sub-feature privileges that, when combined, grant equivalent access
|
||||
* if the feature this privilege belongs to becomes deprecated. The extended definition allows separate lists of
|
||||
* privileges to be defined for the default and minimal (excludes any automatically granted sub-feature privileges)
|
||||
* sets. This property can only be set if the feature is marked as deprecated.
|
||||
*/
|
||||
replacedBy?:
|
||||
| readonly FeatureKibanaPrivilegesReference[]
|
||||
| {
|
||||
default: readonly FeatureKibanaPrivilegesReference[];
|
||||
minimal: readonly FeatureKibanaPrivilegesReference[];
|
||||
};
|
||||
}
|
||||
|
|
|
@ -164,6 +164,18 @@ export interface KibanaFeatureConfig {
|
|||
* Indicates whether the feature is available in Security Feature Privileges and the Spaces Visibility Toggles.
|
||||
*/
|
||||
scope?: readonly KibanaFeatureScope[];
|
||||
|
||||
/**
|
||||
* If defined, the feature is considered deprecated and won't be available to users when configuring roles or Spaces.
|
||||
*/
|
||||
readonly deprecated?: Readonly<{
|
||||
/**
|
||||
* The mandatory, localizable, user-facing notice that will be displayed to users whenever we need to explain why a
|
||||
* feature is deprecated and what they should rely on instead. The notice can also include links to more detailed
|
||||
* documentation.
|
||||
*/
|
||||
notice: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class KibanaFeature {
|
||||
|
@ -179,6 +191,10 @@ export class KibanaFeature {
|
|||
return this.config.id;
|
||||
}
|
||||
|
||||
public get deprecated() {
|
||||
return this.config.deprecated;
|
||||
}
|
||||
|
||||
public get hidden() {
|
||||
return this.config.hidden;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { RecursiveReadonly } from '@kbn/utility-types';
|
||||
import { LicenseType } from '@kbn/licensing-plugin/common/types';
|
||||
import { FeatureKibanaPrivilegesReference } from './feature_kibana_privileges_reference';
|
||||
import { FeatureKibanaPrivileges } from './feature_kibana_privileges';
|
||||
|
||||
/**
|
||||
|
@ -70,7 +71,7 @@ export interface SubFeaturePrivilegeGroupConfig {
|
|||
* Configuration for a sub-feature privilege.
|
||||
*/
|
||||
export interface SubFeaturePrivilegeConfig
|
||||
extends Omit<FeatureKibanaPrivileges, 'excludeFromBasePrivileges' | 'composedOf'> {
|
||||
extends Omit<FeatureKibanaPrivileges, 'excludeFromBasePrivileges' | 'composedOf' | 'replacedBy'> {
|
||||
/**
|
||||
* Identifier for this privilege. Must be unique across all other privileges within a feature.
|
||||
*/
|
||||
|
@ -93,6 +94,13 @@ export interface SubFeaturePrivilegeConfig
|
|||
* that are valid for the overall feature.
|
||||
*/
|
||||
minimumLicense?: LicenseType;
|
||||
|
||||
/**
|
||||
* An optional list of other registered feature or sub-feature privileges that, when combined, grant equivalent access
|
||||
* if the feature this sub-feature privilege belongs to becomes deprecated. This property can only be set if the
|
||||
* feature is marked as deprecated.
|
||||
*/
|
||||
replacedBy?: readonly FeatureKibanaPrivilegesReference[];
|
||||
}
|
||||
|
||||
export class SubFeature {
|
||||
|
|
|
@ -1013,6 +1013,17 @@ Array [
|
|||
},
|
||||
"privilegeId": "all",
|
||||
},
|
||||
Object {
|
||||
"privilege": Object {
|
||||
"disabled": true,
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [],
|
||||
},
|
||||
"privilegeId": "read",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
|
@ -1635,6 +1646,17 @@ Array [
|
|||
},
|
||||
"privilegeId": "all",
|
||||
},
|
||||
Object {
|
||||
"privilege": Object {
|
||||
"disabled": true,
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [],
|
||||
},
|
||||
"privilegeId": "read",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
*/
|
||||
|
||||
import { FeatureRegistry } from './feature_registry';
|
||||
import { ElasticsearchFeatureConfig, KibanaFeatureConfig } from '../common';
|
||||
import {
|
||||
ElasticsearchFeatureConfig,
|
||||
FeatureKibanaPrivilegesReference,
|
||||
KibanaFeatureConfig,
|
||||
} from '../common';
|
||||
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
|
||||
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
|
||||
|
||||
|
@ -1914,6 +1918,25 @@ describe('FeatureRegistry', () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
deprecated: { notice: 'It was a mistake.' },
|
||||
id: 'deprecated-feature',
|
||||
name: 'Deprecated Feature',
|
||||
app: [],
|
||||
category: { id: 'deprecated', label: 'deprecated' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'with-sub-feature', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'with-sub-feature', privileges: ['all'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const registry = new FeatureRegistry();
|
||||
|
@ -1922,7 +1945,12 @@ describe('FeatureRegistry', () => {
|
|||
|
||||
it('returns all features and sub-feature privileges by default', () => {
|
||||
const result = registry.getAllKibanaFeatures();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((f) => f.id)).toEqual([
|
||||
'gold-feature',
|
||||
'unlicensed-feature',
|
||||
'with-sub-feature',
|
||||
'deprecated-feature',
|
||||
]);
|
||||
const [, , withSubFeature] = result;
|
||||
expect(withSubFeature.subFeatures).toHaveLength(1);
|
||||
expect(withSubFeature.subFeatures[0].privilegeGroups).toHaveLength(1);
|
||||
|
@ -1931,18 +1959,32 @@ describe('FeatureRegistry', () => {
|
|||
|
||||
it('returns features which are satisfied by the current license', () => {
|
||||
const license = licensingMock.createLicense({ license: { type: 'gold' } });
|
||||
const result = registry.getAllKibanaFeatures(license);
|
||||
expect(result).toHaveLength(2);
|
||||
const ids = result.map((f) => f.id);
|
||||
expect(ids).toEqual(['gold-feature', 'unlicensed-feature']);
|
||||
const result = registry.getAllKibanaFeatures({ license });
|
||||
expect(result.map((f) => f.id)).toEqual([
|
||||
'gold-feature',
|
||||
'unlicensed-feature',
|
||||
'deprecated-feature',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can omit deprecated features if requested', () => {
|
||||
const result = registry.getAllKibanaFeatures({ omitDeprecated: true });
|
||||
expect(result.map((f) => f.id)).toEqual([
|
||||
'gold-feature',
|
||||
'unlicensed-feature',
|
||||
'with-sub-feature',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out sub-feature privileges which do not match the current license', () => {
|
||||
const license = licensingMock.createLicense({ license: { type: 'platinum' } });
|
||||
const result = registry.getAllKibanaFeatures(license);
|
||||
expect(result).toHaveLength(3);
|
||||
const ids = result.map((f) => f.id);
|
||||
expect(ids).toEqual(['gold-feature', 'unlicensed-feature', 'with-sub-feature']);
|
||||
const result = registry.getAllKibanaFeatures({ license });
|
||||
expect(result.map((f) => f.id)).toEqual([
|
||||
'gold-feature',
|
||||
'unlicensed-feature',
|
||||
'with-sub-feature',
|
||||
'deprecated-feature',
|
||||
]);
|
||||
|
||||
const [, , withSubFeature] = result;
|
||||
expect(withSubFeature.subFeatures).toHaveLength(1);
|
||||
|
@ -2214,6 +2256,669 @@ describe('FeatureRegistry', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#validateFeatures', () => {
|
||||
function createRegistry(...features: KibanaFeatureConfig[]) {
|
||||
const registry = new FeatureRegistry();
|
||||
|
||||
// Non-deprecated feature.
|
||||
const featureBeta: KibanaFeatureConfig = {
|
||||
id: 'feature-beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
privileges: {
|
||||
all: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
read: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-beta-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-beta-1-1',
|
||||
name: 'Sub Beta 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
{
|
||||
id: 'sub-beta-1-2',
|
||||
name: 'Sub Beta 1-2',
|
||||
includeIn: 'read',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sub-beta-2',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-beta-2-1',
|
||||
name: 'Sub Beta 2-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Deprecated feature
|
||||
const featureGamma: KibanaFeatureConfig = {
|
||||
deprecated: { notice: 'It was a mistake.' },
|
||||
id: 'feature-gamma',
|
||||
name: 'Feature Gamma',
|
||||
app: [],
|
||||
category: { id: 'gamma', label: 'gamma' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-gamma-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-gamma-1-1',
|
||||
name: 'Sub Gamma 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [
|
||||
{ feature: 'feature-beta', privileges: ['read', 'sub-beta-2-1'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Non-deprecated feature with disabled privileges.
|
||||
const featureDelta: KibanaFeatureConfig = {
|
||||
id: 'feature-delta',
|
||||
name: 'Feature Delta',
|
||||
app: [],
|
||||
category: { id: 'delta', label: 'delta' },
|
||||
privileges: {
|
||||
all: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
read: { savedObject: { all: [], read: [] }, ui: [], disabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
for (const feature of [featureBeta, featureGamma, featureDelta, ...features]) {
|
||||
registry.registerKibanaFeature(feature);
|
||||
}
|
||||
|
||||
registry.lockRegistration();
|
||||
return registry;
|
||||
}
|
||||
|
||||
function createDeprecatedFeature({
|
||||
all,
|
||||
read,
|
||||
subAlpha,
|
||||
}: {
|
||||
all?: FeatureKibanaPrivilegesReference[];
|
||||
read?: {
|
||||
minimal: FeatureKibanaPrivilegesReference[];
|
||||
default: FeatureKibanaPrivilegesReference[];
|
||||
};
|
||||
subAlpha?: FeatureKibanaPrivilegesReference[];
|
||||
} = {}): KibanaFeatureConfig {
|
||||
return {
|
||||
deprecated: { notice: 'It was a mistake.' },
|
||||
id: 'feature-alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: all ?? [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: read ?? {
|
||||
default: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
minimal: [{ feature: 'feature-beta', privileges: ['read', 'sub-beta-2-1'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-1-1',
|
||||
name: 'Sub Alpha 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: subAlpha ?? [
|
||||
{ feature: 'feature-beta', privileges: ['sub-beta-1-1'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('requires feature to be deprecated to define privilege replacements', () => {
|
||||
const featureAlpha: KibanaFeatureConfig = {
|
||||
id: 'feature-alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
read: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// Case 1: some top-level privileges define replacement.
|
||||
let registry = createRegistry({
|
||||
...featureAlpha,
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
},
|
||||
read: featureAlpha.privileges?.read!,
|
||||
},
|
||||
});
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is not deprecated and must not define a \\"replacedBy\\" property for privilege \\"all\\"."`
|
||||
);
|
||||
|
||||
// Case 2: some sub-feature privileges define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlpha,
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha',
|
||||
name: 'Sub Alpha',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['sub-alpha'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is not deprecated and must not define a \\"replacedBy\\" property for privilege \\"sub-alpha\\"."`
|
||||
);
|
||||
|
||||
// Case 3: none of the privileges define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlpha,
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha',
|
||||
name: 'Sub Alpha',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => registry.validateFeatures()).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires all top-level privileges of the deprecated feature to define replacement', () => {
|
||||
const featureAlphaDeprecated: KibanaFeatureConfig = {
|
||||
deprecated: { notice: 'It was a mistake.' },
|
||||
id: 'feature-alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
read: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// Case 1: all top-level privileges don't define replacement.
|
||||
let registry = createRegistry(featureAlphaDeprecated);
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"all\\"."`
|
||||
);
|
||||
|
||||
// Case 2: some top-level privileges don't define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlphaDeprecated,
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
},
|
||||
read: { savedObject: { all: [], read: [] }, ui: [] },
|
||||
},
|
||||
});
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"read\\"."`
|
||||
);
|
||||
|
||||
// Case 3: all top-level privileges define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlphaDeprecated,
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => registry.validateFeatures()).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires all sub-feature privileges of the deprecated feature to define replacement', () => {
|
||||
const featureAlphaDeprecated: KibanaFeatureConfig = {
|
||||
deprecated: { notice: 'It was a mistake.' },
|
||||
id: 'feature-alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
minimal: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-1-1',
|
||||
name: 'Sub Alpha 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
{
|
||||
id: 'sub-alpha-1-2',
|
||||
name: 'Sub Alpha 1-2',
|
||||
includeIn: 'read',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sub-alpha-2',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-2-1',
|
||||
name: 'Sub Alpha 2-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Case 1: all sub-feature privileges don't define replacement.
|
||||
let registry = createRegistry(featureAlphaDeprecated);
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"sub-alpha-1-1\\"."`
|
||||
);
|
||||
|
||||
// Case 2: some sub-feature privileges of some sub-features don't define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlphaDeprecated,
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-1-1',
|
||||
name: 'Sub Alpha 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
{
|
||||
id: 'sub-alpha-1-2',
|
||||
name: 'Sub Alpha 1-2',
|
||||
includeIn: 'read',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
featureAlphaDeprecated.subFeatures?.[1]!,
|
||||
],
|
||||
});
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"sub-alpha-1-2\\"."`
|
||||
);
|
||||
|
||||
// Case 3: all sub-feature privileges of some sub-features don't define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlphaDeprecated,
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-1-1',
|
||||
name: 'Sub Alpha 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
{
|
||||
id: 'sub-alpha-1-2',
|
||||
name: 'Sub Alpha 1-2',
|
||||
includeIn: 'read',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
featureAlphaDeprecated.subFeatures?.[1]!,
|
||||
],
|
||||
});
|
||||
expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"sub-alpha-2-1\\"."`
|
||||
);
|
||||
|
||||
// Case 4: all top-level and sub-feature privileges define replacement.
|
||||
registry = createRegistry({
|
||||
...featureAlphaDeprecated,
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-alpha-1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-1-1',
|
||||
name: 'Sub Alpha 1-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
{
|
||||
id: 'sub-alpha-1-2',
|
||||
name: 'Sub Alpha 1-2',
|
||||
includeIn: 'read',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sub-alpha-2',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-alpha-2-1',
|
||||
name: 'Sub Alpha 2-1',
|
||||
includeIn: 'all',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => registry.validateFeatures()).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires referenced feature to exist', () => {
|
||||
// Case 1: top-level privilege references to a non-existent feature.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({ all: [{ feature: 'feature-unknown', privileges: ['all'] }] })
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-unknown\\" since such feature is not registered."`
|
||||
);
|
||||
|
||||
// Case 2: top-level privilege references to a non-existent feature (extended format).
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
read: {
|
||||
default: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
minimal: [{ feature: 'feature-unknown', privileges: ['read', 'sub-beta-2-1'] }],
|
||||
},
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-unknown\\" since such feature is not registered."`
|
||||
);
|
||||
|
||||
// Case 3: sub-feature privilege references to a non-existent feature.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
subAlpha: [{ feature: 'feature-unknown', privileges: ['sub-beta-1-1'] }],
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-unknown\\" since such feature is not registered."`
|
||||
);
|
||||
|
||||
// Case 4: all top-level and sub-feature privileges define proper replacement.
|
||||
expect(() => createRegistry(createDeprecatedFeature()).validateFeatures()).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires referenced feature to not be deprecated', () => {
|
||||
// Case 1: top-level privilege references to a deprecated feature.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({ all: [{ feature: 'feature-gamma', privileges: ['all'] }] })
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-gamma\\" since the referenced feature is deprecated."`
|
||||
);
|
||||
|
||||
// Case 2: top-level privilege references to a deprecated feature (extended format).
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
read: {
|
||||
default: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
minimal: [{ feature: 'feature-gamma', privileges: ['read'] }],
|
||||
},
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-gamma\\" since the referenced feature is deprecated."`
|
||||
);
|
||||
|
||||
// Case 3: sub-feature privilege references to a deprecated feature.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
subAlpha: [{ feature: 'feature-gamma', privileges: ['sub-gamma-1-1'] }],
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-gamma\\" since the referenced feature is deprecated."`
|
||||
);
|
||||
});
|
||||
|
||||
it('requires referenced privilege to exist', () => {
|
||||
// Case 1: top-level privilege references to a non-existent privilege.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({ all: [{ feature: 'feature-beta', privileges: ['all_v2'] }] })
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with privilege \\"all_v2\\" of feature \\"feature-beta\\" since such privilege is not registered."`
|
||||
);
|
||||
|
||||
// Case 2: top-level privilege references to a non-existent privilege (extended format).
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
read: {
|
||||
default: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
minimal: [{ feature: 'feature-beta', privileges: ['read_v2'] }],
|
||||
},
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with privilege \\"read_v2\\" of feature \\"feature-beta\\" since such privilege is not registered."`
|
||||
);
|
||||
|
||||
// Case 3: sub-feature privilege references to a non-existent privilege.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
subAlpha: [{ feature: 'feature-beta', privileges: ['sub-gamma-1-1_v2'] }],
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with privilege \\"sub-gamma-1-1_v2\\" of feature \\"feature-beta\\" since such privilege is not registered."`
|
||||
);
|
||||
});
|
||||
|
||||
it('requires referenced privilege to not be disabled', () => {
|
||||
// Case 1: top-level privilege references to a disabled privilege.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({ all: [{ feature: 'feature-delta', privileges: ['read'] }] })
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with disabled privilege \\"read\\" of feature \\"feature-delta\\"."`
|
||||
);
|
||||
|
||||
// Case 2: top-level privilege references to a disabled privilege (extended format).
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
read: {
|
||||
default: [{ feature: 'feature-beta', privileges: ['all'] }],
|
||||
minimal: [{ feature: 'feature-delta', privileges: ['all', 'read'] }],
|
||||
},
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with disabled privilege \\"read\\" of feature \\"feature-delta\\"."`
|
||||
);
|
||||
|
||||
// Case 3: sub-feature privilege references to a disabled privilege.
|
||||
expect(() =>
|
||||
createRegistry(
|
||||
createDeprecatedFeature({
|
||||
subAlpha: [{ feature: 'feature-delta', privileges: ['read'] }],
|
||||
})
|
||||
).validateFeatures()
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with disabled privilege \\"read\\" of feature \\"feature-delta\\"."`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Elasticsearch Features', () => {
|
||||
|
|
|
@ -20,6 +20,27 @@ import {
|
|||
import { validateKibanaFeature, validateElasticsearchFeature } from './feature_schema';
|
||||
import type { ConfigOverridesType } from './config';
|
||||
|
||||
/**
|
||||
* Describes parameters used to retrieve all Kibana features.
|
||||
*/
|
||||
export interface GetKibanaFeaturesParams {
|
||||
/**
|
||||
* If provided, the license will be used to filter out features that require a license higher than the specified one.
|
||||
* */
|
||||
license?: ILicense;
|
||||
|
||||
/**
|
||||
* If true, features that require a license higher than the one provided in the `license` will be included.
|
||||
*/
|
||||
ignoreLicense?: boolean;
|
||||
|
||||
/**
|
||||
* If true, deprecated features will be omitted. For backward compatibility reasons, deprecated features are included
|
||||
* in the result by default.
|
||||
*/
|
||||
omitDeprecated?: boolean;
|
||||
}
|
||||
|
||||
export class FeatureRegistry {
|
||||
private locked = false;
|
||||
private kibanaFeatures: Record<string, KibanaFeatureConfig> = {};
|
||||
|
@ -169,19 +190,106 @@ export class FeatureRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
public getAllKibanaFeatures(license?: ILicense, ignoreLicense = false): KibanaFeature[] {
|
||||
/**
|
||||
* Once all features are registered and the registry is locked, this method should validate the integrity of the registered feature set, including any potential cross-feature dependencies.
|
||||
*/
|
||||
public validateFeatures() {
|
||||
if (!this.locked) {
|
||||
throw new Error(
|
||||
'Cannot validate features while the registry is not locked and still allows further feature registrations.'
|
||||
);
|
||||
}
|
||||
|
||||
for (const feature of Object.values(this.kibanaFeatures)) {
|
||||
if (!feature.privileges) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Iterate over all top-level and sub-feature privileges.
|
||||
const isFeatureDeprecated = !!feature.deprecated;
|
||||
for (const [privilegeId, privilege] of [
|
||||
...Object.entries(feature.privileges),
|
||||
...collectSubFeaturesPrivileges(feature),
|
||||
]) {
|
||||
if (isFeatureDeprecated && !privilege.replacedBy) {
|
||||
throw new Error(
|
||||
`Feature "${feature.id}" is deprecated and must define a "replacedBy" property for privilege "${privilegeId}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFeatureDeprecated && privilege.replacedBy) {
|
||||
throw new Error(
|
||||
`Feature "${feature.id}" is not deprecated and must not define a "replacedBy" property for privilege "${privilegeId}".`
|
||||
);
|
||||
}
|
||||
|
||||
const replacedByReferences = privilege.replacedBy
|
||||
? 'default' in privilege.replacedBy
|
||||
? [...privilege.replacedBy.default, ...privilege.replacedBy.minimal]
|
||||
: privilege.replacedBy
|
||||
: [];
|
||||
for (const featureReference of replacedByReferences) {
|
||||
const referencedFeature = this.kibanaFeatures[featureReference.feature];
|
||||
if (!referencedFeature) {
|
||||
throw new Error(
|
||||
`Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with privileges of feature "${featureReference.feature}" since such feature is not registered.`
|
||||
);
|
||||
}
|
||||
|
||||
if (referencedFeature.deprecated) {
|
||||
throw new Error(
|
||||
`Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with privileges of feature "${featureReference.feature}" since the referenced feature is deprecated.`
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all known feature and sub-feature privileges for the referenced feature.
|
||||
const knownPrivileges = new Map(
|
||||
collectPrivileges(referencedFeature).concat(
|
||||
collectSubFeaturesPrivileges(referencedFeature)
|
||||
)
|
||||
);
|
||||
|
||||
for (const privilegeReference of featureReference.privileges) {
|
||||
const referencedPrivilege = knownPrivileges.get(privilegeReference);
|
||||
if (!referencedPrivilege) {
|
||||
throw new Error(
|
||||
`Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with privilege "${privilegeReference}" of feature "${featureReference.feature}" since such privilege is not registered.`
|
||||
);
|
||||
}
|
||||
|
||||
if (referencedPrivilege.disabled) {
|
||||
throw new Error(
|
||||
`Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with disabled privilege "${privilegeReference}" of feature "${featureReference.feature}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getAllKibanaFeatures({
|
||||
license,
|
||||
ignoreLicense = false,
|
||||
omitDeprecated = false,
|
||||
}: GetKibanaFeaturesParams = {}): KibanaFeature[] {
|
||||
if (!this.locked) {
|
||||
throw new Error('Cannot retrieve Kibana features while registration is still open');
|
||||
}
|
||||
|
||||
let features = Object.values(this.kibanaFeatures);
|
||||
|
||||
const performLicenseCheck = license && !ignoreLicense;
|
||||
const features = [];
|
||||
for (const feature of Object.values(this.kibanaFeatures)) {
|
||||
if (omitDeprecated && feature.deprecated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (performLicenseCheck) {
|
||||
features = features.filter((feature) => {
|
||||
const filter = !feature.minimumLicense || license!.hasAtLeast(feature.minimumLicense);
|
||||
if (!filter) return false;
|
||||
if (performLicenseCheck) {
|
||||
const isCompatibleLicense =
|
||||
!feature.minimumLicense || license!.hasAtLeast(feature.minimumLicense);
|
||||
if (!isCompatibleLicense) {
|
||||
continue;
|
||||
}
|
||||
|
||||
feature.subFeatures?.forEach((subFeature) => {
|
||||
subFeature.privilegeGroups.forEach((group) => {
|
||||
|
@ -191,11 +299,12 @@ export class FeatureRegistry {
|
|||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
features.push(new KibanaFeature(feature));
|
||||
}
|
||||
return features.map((featureConfig) => new KibanaFeature(featureConfig));
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
public getAllElasticsearchFeatures(): ElasticsearchFeature[] {
|
||||
|
@ -252,6 +361,16 @@ function applyAutomaticReadPrivilegeGrants(
|
|||
});
|
||||
}
|
||||
|
||||
function collectPrivileges(feature: KibanaFeatureConfig) {
|
||||
return Object.entries(feature.privileges ?? {}).flatMap(
|
||||
([id, privilege]) =>
|
||||
[
|
||||
[id, privilege],
|
||||
[`minimal_${id}`, privilege],
|
||||
] as Array<[string, FeatureKibanaPrivileges]>
|
||||
);
|
||||
}
|
||||
|
||||
function collectSubFeaturesPrivileges(feature: KibanaFeatureConfig) {
|
||||
return (
|
||||
feature.subFeatures?.flatMap((subFeature) =>
|
||||
|
|
|
@ -116,6 +116,21 @@ const kibanaPrivilegeSchema = schema.object({
|
|||
read: schema.arrayOf(schema.string()),
|
||||
}),
|
||||
ui: listOfCapabilitiesSchema,
|
||||
replacedBy: schema.maybe(
|
||||
schema.oneOf([
|
||||
schema.arrayOf(
|
||||
schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) })
|
||||
),
|
||||
schema.object({
|
||||
minimal: schema.arrayOf(
|
||||
schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) })
|
||||
),
|
||||
default: schema.arrayOf(
|
||||
schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) })
|
||||
),
|
||||
}),
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({
|
||||
|
@ -155,6 +170,11 @@ const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({
|
|||
read: schema.arrayOf(schema.string()),
|
||||
}),
|
||||
ui: listOfCapabilitiesSchema,
|
||||
replacedBy: schema.maybe(
|
||||
schema.arrayOf(
|
||||
schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) })
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
const kibanaMutuallyExclusiveSubFeaturePrivilegeSchema =
|
||||
|
@ -256,6 +276,7 @@ const kibanaFeatureSchema = schema.object({
|
|||
),
|
||||
})
|
||||
),
|
||||
deprecated: schema.maybe(schema.object({ notice: schema.string() })),
|
||||
});
|
||||
|
||||
const elasticsearchPrivilegeSchema = schema.object({
|
||||
|
|
|
@ -21,6 +21,7 @@ export type {
|
|||
ElasticsearchFeatureConfig,
|
||||
FeatureElasticsearchPrivileges,
|
||||
} from '../common';
|
||||
export type { SubFeaturePrivilegeIterator } from './feature_privilege_iterator';
|
||||
export { KibanaFeature, ElasticsearchFeature } from '../common';
|
||||
export type { FeaturesPluginSetup, FeaturesPluginStart } from './plugin';
|
||||
|
||||
|
|
|
@ -555,10 +555,12 @@ export const buildOSSFeatures = ({
|
|||
read: [],
|
||||
},
|
||||
ui: ['saveQuery'],
|
||||
}, // No read-only mode supported
|
||||
},
|
||||
// No read-only mode supported
|
||||
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
|
||||
},
|
||||
},
|
||||
] as KibanaFeatureConfig[];
|
||||
];
|
||||
};
|
||||
|
||||
const reportingPrivilegeGroupName = i18n.translate(
|
||||
|
|
|
@ -135,6 +135,7 @@ export class FeaturesPlugin
|
|||
}
|
||||
|
||||
this.featureRegistry.lockRegistration();
|
||||
this.featureRegistry.validateFeatures();
|
||||
|
||||
this.capabilities = uiCapabilitiesForFeatures(
|
||||
this.featureRegistry.getAllKibanaFeatures(),
|
||||
|
|
|
@ -44,6 +44,7 @@ function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatu
|
|||
name: 'basic sub 1',
|
||||
includeIn: 'all',
|
||||
...createPrivilege(),
|
||||
replacedBy: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -63,6 +64,7 @@ function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatu
|
|||
includeIn: 'all',
|
||||
minimumLicense: 'platinum',
|
||||
...createPrivilege(),
|
||||
replacedBy: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
@ -75,6 +77,7 @@ function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatu
|
|||
name: 'platinum sub 1',
|
||||
includeIn: 'all',
|
||||
...createPrivilege(),
|
||||
replacedBy: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -126,6 +129,26 @@ describe('GET /api/features', () => {
|
|||
privileges: null,
|
||||
});
|
||||
|
||||
featureRegistry.registerKibanaFeature({
|
||||
deprecated: { notice: 'It was a mistake.' },
|
||||
id: 'deprecated-feature',
|
||||
name: 'Deprecated Feature',
|
||||
app: [],
|
||||
category: { id: 'deprecated', label: 'deprecated' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature_1', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: [],
|
||||
replacedBy: [{ feature: 'feature_1', privileges: ['all'] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
featureRegistry.lockRegistration();
|
||||
|
||||
const routerMock = httpServiceMock.createRouter();
|
||||
|
@ -137,7 +160,7 @@ describe('GET /api/features', () => {
|
|||
routeHandler = routerMock.get.mock.calls[0][1];
|
||||
});
|
||||
|
||||
it('returns a list of available features, sorted by their configured order', async () => {
|
||||
it('returns a list of available features omitting deprecated ones, sorted by their configured order', async () => {
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
await routeHandler(createContextMock(), { query: {} } as any, mockResponse);
|
||||
|
||||
|
|
|
@ -33,10 +33,13 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams)
|
|||
async (context, request, response) => {
|
||||
const { license: currentLicense } = await context.licensing;
|
||||
|
||||
const allFeatures = featureRegistry.getAllKibanaFeatures(
|
||||
currentLicense,
|
||||
request.query.ignoreValidLicenses
|
||||
);
|
||||
const allFeatures = featureRegistry.getAllKibanaFeatures({
|
||||
license: currentLicense,
|
||||
ignoreLicense: request.query.ignoreValidLicenses,
|
||||
// This API is used to power user-facing UIs, which, unlike our server-side internal backward compatibility
|
||||
// mechanisms, shouldn't display deprecated features.
|
||||
omitDeprecated: true,
|
||||
});
|
||||
|
||||
return response.ok({
|
||||
body: allFeatures
|
||||
|
|
|
@ -24,8 +24,6 @@ export type {
|
|||
|
||||
export { getUserDisplayName, isRoleReserved, isRoleWithWildcardBasePrivilege } from './model';
|
||||
|
||||
export type { RawKibanaPrivileges } from '@kbn/security-authorization-core';
|
||||
|
||||
// Re-export types from the plugin directly to enhance the developer experience for consumers of the Security plugin.
|
||||
export type {
|
||||
AuthenticatedUser,
|
||||
|
@ -39,6 +37,8 @@ export type {
|
|||
RoleRemoteClusterPrivilege,
|
||||
FeaturesPrivileges,
|
||||
LoginLayout,
|
||||
RawKibanaPrivileges,
|
||||
RawKibanaFeaturePrivileges,
|
||||
SecurityLicenseFeatures,
|
||||
SecurityLicense,
|
||||
UserProfile,
|
||||
|
|
|
@ -21,10 +21,6 @@ export {
|
|||
} from './authenticated_user';
|
||||
export { shouldProviderUseLoginForm } from './authentication_provider';
|
||||
export type { BuiltinESPrivileges } from './builtin_es_privileges';
|
||||
export type {
|
||||
RawKibanaPrivileges,
|
||||
RawKibanaFeaturePrivileges,
|
||||
} from '@kbn/security-authorization-core';
|
||||
export {
|
||||
copyRole,
|
||||
isRoleDeprecated,
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { HttpStart } from '@kbn/core/public';
|
||||
import type { RawKibanaPrivileges } from '@kbn/security-authorization-core';
|
||||
import type { RawKibanaPrivileges } from '@kbn/security-plugin-types-common';
|
||||
import { PrivilegesAPIClientPublicContract } from '@kbn/security-plugin-types-public';
|
||||
|
||||
import type { BuiltinESPrivileges } from '../../../common/model';
|
||||
|
|
|
@ -337,4 +337,36 @@ describe('RolesAPIClient', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRole', () => {
|
||||
it('should request role with replaced deprecated privileges', async () => {
|
||||
const httpMock = httpServiceMock.createStartContract();
|
||||
const roleName = 'my role';
|
||||
const rolesAPIClient = new RolesAPIClient(httpMock);
|
||||
|
||||
await rolesAPIClient.getRole(roleName);
|
||||
|
||||
expect(httpMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(httpMock.get).toHaveBeenCalledWith(
|
||||
`/api/security/role/${encodeURIComponent(roleName)}`,
|
||||
{
|
||||
query: { replaceDeprecatedPrivileges: true },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRoles', () => {
|
||||
it('should request roles with replaced deprecated privileges', async () => {
|
||||
const httpMock = httpServiceMock.createStartContract();
|
||||
const rolesAPIClient = new RolesAPIClient(httpMock);
|
||||
|
||||
await rolesAPIClient.getRoles();
|
||||
|
||||
expect(httpMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(httpMock.get).toHaveBeenCalledWith('/api/security/role', {
|
||||
query: { replaceDeprecatedPrivileges: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,11 +15,15 @@ export class RolesAPIClient {
|
|||
constructor(private readonly http: HttpStart) {}
|
||||
|
||||
public getRoles = async () => {
|
||||
return await this.http.get<Role[]>('/api/security/role');
|
||||
return await this.http.get<Role[]>('/api/security/role', {
|
||||
query: { replaceDeprecatedPrivileges: true },
|
||||
});
|
||||
};
|
||||
|
||||
public getRole = async (roleName: string) => {
|
||||
return await this.http.get<Role>(`/api/security/role/${encodeURIComponent(roleName)}`);
|
||||
return await this.http.get<Role>(`/api/security/role/${encodeURIComponent(roleName)}`, {
|
||||
query: { replaceDeprecatedPrivileges: true },
|
||||
});
|
||||
};
|
||||
|
||||
public deleteRole = async (roleName: string) => {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { PrivilegeSerializer } from './privilege_serializer';
|
||||
import type { RawKibanaPrivileges } from '../../common/model';
|
||||
import type { RawKibanaPrivileges } from '../../common';
|
||||
|
||||
interface SerializedPrivilege {
|
||||
application: string;
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { Logger } from '@kbn/core/server';
|
|||
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
||||
import { registerPrivilegesWithCluster } from './register_privileges_with_cluster';
|
||||
import type { RawKibanaPrivileges } from '../../common/model';
|
||||
import type { RawKibanaPrivileges } from '../../common';
|
||||
|
||||
const application = 'default-application';
|
||||
const registerPrivilegesWithClusterTest = (
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
import { omit, pick } from 'lodash';
|
||||
|
||||
import { KibanaFeature } from '@kbn/features-plugin/server';
|
||||
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
import { transformElasticsearchRoleToRole } from './elasticsearch_role';
|
||||
import type { ElasticsearchRole } from './elasticsearch_role';
|
||||
import type { ElasticsearchRole, TransformRoleOptions } from './elasticsearch_role';
|
||||
|
||||
const roles = [
|
||||
{
|
||||
|
@ -202,13 +203,14 @@ function testRoles(
|
|||
expected: any
|
||||
) {
|
||||
const transformedRoles = elasticsearchRoles.map((role) => {
|
||||
const transformedRole = transformElasticsearchRoleToRole(
|
||||
const transformedRole = transformElasticsearchRoleToRole({
|
||||
features,
|
||||
omit(role, 'name'),
|
||||
role.name,
|
||||
'kibana-.kibana',
|
||||
loggerMock.create()
|
||||
);
|
||||
elasticsearchRole: omit(role, 'name'),
|
||||
name: role.name,
|
||||
application: 'kibana-.kibana',
|
||||
logger: loggerMock.create(),
|
||||
subFeaturePrivilegeIterator: featuresPluginMock.createSetup().subFeaturePrivilegeIterator,
|
||||
});
|
||||
return pick(transformedRole, ['name', '_transform_error']);
|
||||
});
|
||||
|
||||
|
@ -320,13 +322,14 @@ describe('#transformElasticsearchRoleToRole', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const transformedRole = transformElasticsearchRoleToRole(
|
||||
featuresWithRequireAllSpaces,
|
||||
omit(role, 'name'),
|
||||
role.name,
|
||||
'kibana-.kibana',
|
||||
loggerMock.create()
|
||||
);
|
||||
const transformedRole = transformElasticsearchRoleToRole({
|
||||
features: featuresWithRequireAllSpaces,
|
||||
elasticsearchRole: omit(role, 'name'),
|
||||
name: role.name,
|
||||
application: 'kibana-.kibana',
|
||||
logger: loggerMock.create(),
|
||||
subFeaturePrivilegeIterator: featuresPluginMock.createSetup().subFeaturePrivilegeIterator,
|
||||
});
|
||||
|
||||
const [privilege] = transformedRole.kibana;
|
||||
const [basePrivilege] = privilege.base;
|
||||
|
@ -335,4 +338,367 @@ describe('#transformElasticsearchRoleToRole', () => {
|
|||
expect(basePrivilege).toBe('*');
|
||||
expect(spacePrivilege).toBe('*');
|
||||
});
|
||||
|
||||
it('properly handles privileges from deprecated features', () => {
|
||||
const applicationName = 'kibana-.kibana';
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
deprecated: { notice: 'It is deprecated, sorry.' },
|
||||
id: 'alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-alpha-all-so'],
|
||||
read: ['all-alpha-read-so'],
|
||||
},
|
||||
ui: ['all-alpha-ui'],
|
||||
app: ['all-alpha-app'],
|
||||
api: ['all-alpha-api'],
|
||||
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-alpha-all-so'],
|
||||
read: ['read-alpha-read-so'],
|
||||
},
|
||||
ui: ['read-alpha-ui'],
|
||||
app: ['read-alpha-app'],
|
||||
api: ['read-alpha-api'],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }],
|
||||
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_alpha',
|
||||
name: 'Sub Feature Alpha',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-alpha-all-so'],
|
||||
read: ['sub-alpha-read-so'],
|
||||
},
|
||||
ui: ['sub-alpha-ui'],
|
||||
app: ['sub-alpha-app'],
|
||||
api: ['sub-alpha-api'],
|
||||
replacedBy: [
|
||||
{ feature: 'beta', privileges: ['minimal_read'] },
|
||||
{ feature: 'beta', privileges: ['sub_beta'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
new KibanaFeature({
|
||||
id: 'beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-beta-all-so'],
|
||||
read: ['all-beta-read-so'],
|
||||
},
|
||||
ui: ['all-beta-ui'],
|
||||
app: ['all-beta-app'],
|
||||
api: ['all-beta-api'],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-beta-all-so'],
|
||||
read: ['read-beta-read-so'],
|
||||
},
|
||||
ui: ['read-beta-ui'],
|
||||
app: ['read-beta-app'],
|
||||
api: ['read-beta-api'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-beta',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_beta',
|
||||
name: 'Sub Feature Beta',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-beta-all-so'],
|
||||
read: ['sub-beta-read-so'],
|
||||
},
|
||||
ui: ['sub-beta-ui'],
|
||||
app: ['sub-beta-app'],
|
||||
api: ['sub-beta-api'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
const getTransformRoleParams = (
|
||||
params: Pick<TransformRoleOptions, 'elasticsearchRole' | 'replaceDeprecatedKibanaPrivileges'>
|
||||
) => ({
|
||||
features,
|
||||
name: 'old-role',
|
||||
elasticsearchRole: params.elasticsearchRole,
|
||||
application: applicationName,
|
||||
logger: loggerMock.create(),
|
||||
subFeaturePrivilegeIterator: featuresPluginMock.createSetup().subFeaturePrivilegeIterator,
|
||||
replaceDeprecatedKibanaPrivileges: params.replaceDeprecatedKibanaPrivileges,
|
||||
});
|
||||
|
||||
const getRole = (appPrivileges: string[]) => ({
|
||||
name: 'old-role',
|
||||
cluster: [],
|
||||
remote_cluster: [],
|
||||
indices: [],
|
||||
applications: [{ application: applicationName, privileges: appPrivileges, resources: ['*'] }],
|
||||
run_as: [],
|
||||
metadata: {},
|
||||
transient_metadata: { enabled: true },
|
||||
});
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is false, the deprecated privileges are returned as is.
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole([
|
||||
'feature_alpha.all',
|
||||
'feature_alpha.read',
|
||||
'feature_alpha.minimal_all',
|
||||
'feature_alpha.minimal_read',
|
||||
'feature_alpha.sub_alpha',
|
||||
]),
|
||||
replaceDeprecatedKibanaPrivileges: false,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"alpha": Array [
|
||||
"all",
|
||||
"read",
|
||||
"minimal_all",
|
||||
"minimal_read",
|
||||
"sub_alpha",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The non-deprecated, but referenced privileges aren't affected.
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole([
|
||||
'feature_beta.all',
|
||||
'feature_beta.read',
|
||||
'feature_beta.minimal_all',
|
||||
'feature_beta.minimal_read',
|
||||
'feature_beta.sub_beta',
|
||||
]),
|
||||
replaceDeprecatedKibanaPrivileges: false,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"all",
|
||||
"read",
|
||||
"minimal_all",
|
||||
"minimal_read",
|
||||
"sub_beta",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, top-level privilege is replaced (simple format).
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole(['feature_alpha.all']),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"all",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, top-level privilege is replaced (extended format).
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole(['feature_alpha.read']),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"read",
|
||||
"sub_beta",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, top-level minimal privilege is replaced (simple format).
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole(['feature_alpha.minimal_all']),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"all",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, top-level minimal privilege is replaced (extended format).
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole(['feature_alpha.minimal_read']),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"minimal_read",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, sub-feature privilege is replaced.
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole(['feature_alpha.sub_alpha']),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"minimal_read",
|
||||
"sub_beta",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, replaces all privileges that needed.
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole([
|
||||
'feature_alpha.all',
|
||||
'feature_alpha.read',
|
||||
'feature_alpha.minimal_all',
|
||||
'feature_alpha.minimal_read',
|
||||
'feature_alpha.sub_alpha',
|
||||
'feature_gamma.all',
|
||||
]),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"all",
|
||||
"read",
|
||||
"sub_beta",
|
||||
"minimal_read",
|
||||
],
|
||||
"gamma": Array [
|
||||
"all",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
// The `replaceDeprecatedKibanaPrivileges` is true, replaces and deduplicate privileges.
|
||||
{
|
||||
const kibanaRole = transformElasticsearchRoleToRole(
|
||||
getTransformRoleParams({
|
||||
elasticsearchRole: getRole([
|
||||
'feature_alpha.all',
|
||||
'feature_alpha.read',
|
||||
'feature_alpha.minimal_all',
|
||||
'feature_alpha.minimal_read',
|
||||
'feature_alpha.sub_alpha',
|
||||
'feature_gamma.all',
|
||||
'feature_beta.all',
|
||||
'feature_beta.read',
|
||||
'feature_beta.minimal_all',
|
||||
'feature_beta.minimal_read',
|
||||
'feature_beta.sub_beta',
|
||||
]),
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
})
|
||||
);
|
||||
expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"beta": Array [
|
||||
"all",
|
||||
"read",
|
||||
"sub_beta",
|
||||
"minimal_read",
|
||||
"minimal_all",
|
||||
],
|
||||
"gamma": Array [
|
||||
"all",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,10 +6,13 @@
|
|||
*/
|
||||
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
import type { KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import type { FeatureKibanaPrivileges, KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import type { SubFeaturePrivilegeIterator } from '@kbn/features-plugin/server';
|
||||
import { getReplacedByForPrivilege } from '@kbn/security-authorization-core';
|
||||
import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common';
|
||||
import { GLOBAL_RESOURCE } from '@kbn/security-plugin-types-server';
|
||||
|
||||
import type { Role, RoleKibanaPrivilege } from '../../../common';
|
||||
import type { FeaturesPrivileges, Role } from '../../../common';
|
||||
import {
|
||||
PRIVILEGES_ALL_WILDCARD,
|
||||
RESERVED_PRIVILEGES_APPLICATION_WILDCARD,
|
||||
|
@ -35,21 +38,35 @@ export type ElasticsearchRole = Pick<
|
|||
};
|
||||
|
||||
const isReservedPrivilege = (app: string) => app === RESERVED_PRIVILEGES_APPLICATION_WILDCARD;
|
||||
const isWildcardPrivilage = (app: string) => app === PRIVILEGES_ALL_WILDCARD;
|
||||
const isWildcardPrivilege = (app: string) => app === PRIVILEGES_ALL_WILDCARD;
|
||||
|
||||
export function transformElasticsearchRoleToRole(
|
||||
features: KibanaFeature[],
|
||||
elasticsearchRole: Omit<ElasticsearchRole, 'name'>,
|
||||
name: string,
|
||||
application: string,
|
||||
logger: Logger
|
||||
): Role {
|
||||
const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges(
|
||||
export interface TransformRoleOptions {
|
||||
features: KibanaFeature[];
|
||||
elasticsearchRole: Omit<ElasticsearchRole, 'name'>;
|
||||
name: string;
|
||||
application: string;
|
||||
logger: Logger;
|
||||
subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator;
|
||||
replaceDeprecatedKibanaPrivileges?: boolean;
|
||||
}
|
||||
|
||||
export function transformElasticsearchRoleToRole({
|
||||
features,
|
||||
elasticsearchRole,
|
||||
name,
|
||||
application,
|
||||
logger,
|
||||
subFeaturePrivilegeIterator,
|
||||
replaceDeprecatedKibanaPrivileges,
|
||||
}: TransformRoleOptions): Role {
|
||||
const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges({
|
||||
features,
|
||||
elasticsearchRole.applications,
|
||||
roleApplications: elasticsearchRole.applications,
|
||||
application,
|
||||
logger
|
||||
);
|
||||
logger,
|
||||
subFeaturePrivilegeIterator,
|
||||
replaceDeprecatedKibanaPrivileges,
|
||||
});
|
||||
return {
|
||||
name,
|
||||
...(elasticsearchRole.description && { description: elasticsearchRole.description }),
|
||||
|
@ -71,17 +88,28 @@ export function transformElasticsearchRoleToRole(
|
|||
};
|
||||
}
|
||||
|
||||
function transformRoleApplicationsToKibanaPrivileges(
|
||||
features: KibanaFeature[],
|
||||
roleApplications: ElasticsearchRole['applications'],
|
||||
application: string,
|
||||
logger: Logger
|
||||
) {
|
||||
interface TransformRoleApplicationsOptions {
|
||||
features: KibanaFeature[];
|
||||
roleApplications: ElasticsearchRole['applications'];
|
||||
application: string;
|
||||
logger: Logger;
|
||||
subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator;
|
||||
replaceDeprecatedKibanaPrivileges?: boolean;
|
||||
}
|
||||
|
||||
function transformRoleApplicationsToKibanaPrivileges({
|
||||
features,
|
||||
roleApplications,
|
||||
application,
|
||||
logger,
|
||||
subFeaturePrivilegeIterator,
|
||||
replaceDeprecatedKibanaPrivileges,
|
||||
}: TransformRoleApplicationsOptions) {
|
||||
const roleKibanaApplications = roleApplications.filter(
|
||||
(roleApplication) =>
|
||||
roleApplication.application === application ||
|
||||
isReservedPrivilege(roleApplication.application) ||
|
||||
isWildcardPrivilage(roleApplication.application)
|
||||
isWildcardPrivilege(roleApplication.application)
|
||||
);
|
||||
|
||||
// if any application entry contains an empty resource, we throw an error
|
||||
|
@ -94,11 +122,11 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
if (
|
||||
roleKibanaApplications.some(
|
||||
(entry) =>
|
||||
(isReservedPrivilege(entry.application) || isWildcardPrivilage(entry.application)) &&
|
||||
(isReservedPrivilege(entry.application) || isWildcardPrivilege(entry.application)) &&
|
||||
!entry.privileges.every(
|
||||
(privilege) =>
|
||||
PrivilegeSerializer.isSerializedReservedPrivilege(privilege) ||
|
||||
isWildcardPrivilage(privilege)
|
||||
isWildcardPrivilege(privilege)
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
@ -112,7 +140,7 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
roleKibanaApplications.some(
|
||||
(entry) =>
|
||||
!isReservedPrivilege(entry.application) &&
|
||||
!isWildcardPrivilage(entry.application) &&
|
||||
!isWildcardPrivilege(entry.application) &&
|
||||
entry.privileges.some((privilege) =>
|
||||
PrivilegeSerializer.isSerializedReservedPrivilege(privilege)
|
||||
)
|
||||
|
@ -188,7 +216,7 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
|
||||
const allResources = roleKibanaApplications
|
||||
.filter(
|
||||
(entry) => !isReservedPrivilege(entry.application) && !isWildcardPrivilage(entry.application)
|
||||
(entry) => !isReservedPrivilege(entry.application) && !isWildcardPrivilege(entry.application)
|
||||
)
|
||||
.flatMap((entry) => entry.resources);
|
||||
|
||||
|
@ -252,6 +280,13 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
// try/catch block ensures graceful return on deserialize exceptions
|
||||
try {
|
||||
const transformResult = roleKibanaApplications.map(({ resources, privileges }) => {
|
||||
const featurePrivileges = deserializeKibanaFeaturePrivileges({
|
||||
features,
|
||||
subFeaturePrivilegeIterator,
|
||||
serializedPrivileges: privileges,
|
||||
replaceDeprecatedKibanaPrivileges,
|
||||
});
|
||||
|
||||
// if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array
|
||||
if (resources.length === 1 && resources[0] === GLOBAL_RESOURCE) {
|
||||
const reservedPrivileges = privileges.filter((privilege) =>
|
||||
|
@ -260,10 +295,6 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
const basePrivileges = privileges.filter((privilege) =>
|
||||
PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege)
|
||||
);
|
||||
const featurePrivileges = privileges.filter((privilege) =>
|
||||
PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)
|
||||
);
|
||||
|
||||
return {
|
||||
...(reservedPrivileges.length
|
||||
? {
|
||||
|
@ -275,14 +306,7 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
base: basePrivileges.map((privilege) =>
|
||||
PrivilegeSerializer.serializeGlobalBasePrivilege(privilege)
|
||||
),
|
||||
feature: featurePrivileges.reduce((acc, privilege) => {
|
||||
const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege);
|
||||
acc[featurePrivilege.featureId] = getUniqueList([
|
||||
...(acc[featurePrivilege.featureId] || []),
|
||||
featurePrivilege.privilege,
|
||||
]);
|
||||
return acc;
|
||||
}, {} as RoleKibanaPrivilege['feature']),
|
||||
feature: featurePrivileges,
|
||||
spaces: ['*'],
|
||||
};
|
||||
}
|
||||
|
@ -290,21 +314,11 @@ function transformRoleApplicationsToKibanaPrivileges(
|
|||
const basePrivileges = privileges.filter((privilege) =>
|
||||
PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege)
|
||||
);
|
||||
const featurePrivileges = privileges.filter((privilege) =>
|
||||
PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)
|
||||
);
|
||||
return {
|
||||
base: basePrivileges.map((privilege) =>
|
||||
PrivilegeSerializer.deserializeSpaceBasePrivilege(privilege)
|
||||
),
|
||||
feature: featurePrivileges.reduce((acc, privilege) => {
|
||||
const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege);
|
||||
acc[featurePrivilege.featureId] = getUniqueList([
|
||||
...(acc[featurePrivilege.featureId] || []),
|
||||
featurePrivilege.privilege,
|
||||
]);
|
||||
return acc;
|
||||
}, {} as RoleKibanaPrivilege['feature']),
|
||||
feature: featurePrivileges,
|
||||
spaces: resources.map((resource) => ResourceSerializer.deserializeSpaceResource(resource)),
|
||||
};
|
||||
});
|
||||
|
@ -331,7 +345,7 @@ const extractUnrecognizedApplicationNames = (
|
|||
(roleApplication) =>
|
||||
roleApplication.application !== application &&
|
||||
!isReservedPrivilege(roleApplication.application) &&
|
||||
!isWildcardPrivilage(roleApplication.application)
|
||||
!isWildcardPrivilege(roleApplication.application)
|
||||
)
|
||||
.map((roleApplication) => roleApplication.application)
|
||||
);
|
||||
|
@ -352,3 +366,83 @@ export const compareRolesByName = (roleA: Role, roleB: Role) => {
|
|||
|
||||
return 0;
|
||||
};
|
||||
|
||||
interface DeserializeFeaturePrivilegesOptions {
|
||||
features: KibanaFeature[];
|
||||
serializedPrivileges: string[];
|
||||
subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator;
|
||||
replaceDeprecatedKibanaPrivileges?: boolean;
|
||||
}
|
||||
|
||||
function deserializeKibanaFeaturePrivileges({
|
||||
features,
|
||||
subFeaturePrivilegeIterator,
|
||||
serializedPrivileges,
|
||||
replaceDeprecatedKibanaPrivileges,
|
||||
}: DeserializeFeaturePrivilegesOptions) {
|
||||
// Filter out deprecated features upfront to avoid going through ALL features within a loop.
|
||||
const deprecatedFeatures = replaceDeprecatedKibanaPrivileges
|
||||
? features.filter((feature) => feature.deprecated)
|
||||
: undefined;
|
||||
const result = {} as FeaturesPrivileges;
|
||||
for (const serializedPrivilege of serializedPrivileges) {
|
||||
if (!PrivilegeSerializer.isSerializedFeaturePrivilege(serializedPrivilege)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { featureId, privilege: privilegeId } =
|
||||
PrivilegeSerializer.deserializeFeaturePrivilege(serializedPrivilege);
|
||||
|
||||
// If feature privileges are deprecated, replace them with non-deprecated feature privileges according to the
|
||||
// deprecation "mapping".
|
||||
const deprecatedFeature = deprecatedFeatures?.find((feature) => feature.id === featureId);
|
||||
if (deprecatedFeature) {
|
||||
const privilege = getPrivilegeById(
|
||||
deprecatedFeature,
|
||||
privilegeId,
|
||||
subFeaturePrivilegeIterator
|
||||
);
|
||||
|
||||
const replacedBy = privilege ? getReplacedByForPrivilege(privilegeId, privilege) : undefined;
|
||||
if (!replacedBy) {
|
||||
throw new Error(
|
||||
`A deprecated feature "${featureId}" is missing a replacement for the "${privilegeId}" privilege.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const reference of replacedBy) {
|
||||
result[reference.feature] = getUniqueList([
|
||||
...(result[reference.feature] || []),
|
||||
...reference.privileges,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
result[featureId] = getUniqueList([...(result[featureId] || []), privilegeId]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPrivilegeById(
|
||||
feature: KibanaFeature,
|
||||
privilegeId: string,
|
||||
subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator
|
||||
): FeatureKibanaPrivileges | undefined {
|
||||
for (const topLevelPrivilege of ['all' as const, 'read' as const]) {
|
||||
if (
|
||||
privilegeId === topLevelPrivilege ||
|
||||
privilegeId === getMinimalPrivilegeId(topLevelPrivilege)
|
||||
) {
|
||||
return feature.privileges?.[topLevelPrivilege];
|
||||
}
|
||||
}
|
||||
|
||||
// Don't perform license check as it should be done during feature registration (once we support
|
||||
// license checks for deprecated privileges).
|
||||
for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature, () => true)) {
|
||||
if (subFeaturePrivilege.id === privilegeId) {
|
||||
return subFeaturePrivilege;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
*/
|
||||
|
||||
import type { KibanaFeature } from '@kbn/features-plugin/server';
|
||||
import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common';
|
||||
|
||||
export function validateFeaturePrivileges(features: KibanaFeature[]) {
|
||||
for (const feature of features) {
|
||||
const seenPrivilegeIds = new Set<string>();
|
||||
Object.keys(feature.privileges ?? {}).forEach((privilegeId) => {
|
||||
seenPrivilegeIds.add(privilegeId);
|
||||
seenPrivilegeIds.add(`minimal_${privilegeId}`);
|
||||
seenPrivilegeIds.add(getMinimalPrivilegeId(privilegeId));
|
||||
});
|
||||
|
||||
const subFeatureEntries = feature.subFeatures ?? [];
|
||||
|
|
|
@ -46,14 +46,14 @@ export const getPrivilegeDeprecationsService = ({
|
|||
context.esClient.asCurrentUser.security.getRole(),
|
||||
]);
|
||||
kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) =>
|
||||
transformElasticsearchRoleToRole(
|
||||
transformElasticsearchRoleToRole({
|
||||
features,
|
||||
// @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]`
|
||||
elasticsearchRole,
|
||||
roleName,
|
||||
authz.applicationName,
|
||||
logger
|
||||
)
|
||||
name: roleName,
|
||||
application: authz.applicationName,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
const statusCode = getErrorStatusCode(e);
|
||||
|
|
|
@ -331,6 +331,7 @@ export class SecurityPlugin
|
|||
getSession: this.getSession,
|
||||
getFeatures: () =>
|
||||
startServicesPromise.then((services) => services.features.getKibanaFeatures()),
|
||||
subFeaturePrivilegeIterator: features.subFeaturePrivilegeIterator,
|
||||
getFeatureUsageService: this.getFeatureUsageService,
|
||||
getAuthenticationService: this.getAuthentication,
|
||||
getAnonymousAccessService: this.getAnonymousAccess,
|
||||
|
|
|
@ -10,7 +10,7 @@ import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
|
|||
import type { LicenseCheck } from '@kbn/licensing-plugin/server';
|
||||
|
||||
import { defineGetPrivilegesRoutes } from './get';
|
||||
import type { RawKibanaPrivileges } from '../../../../common/model';
|
||||
import type { RawKibanaPrivileges } from '../../../../common';
|
||||
import type { SecurityRequestHandlerContext } from '../../../types';
|
||||
import { routeDefinitionParamsMock } from '../../index.mock';
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ import Boom from '@hapi/boom';
|
|||
|
||||
import { kibanaResponseFactory } from '@kbn/core/server';
|
||||
import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
|
||||
import { KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
|
||||
import type { LicenseCheck } from '@kbn/licensing-plugin/server';
|
||||
|
||||
import { defineGetRolesRoutes } from './get';
|
||||
|
@ -22,17 +24,133 @@ interface TestOptions {
|
|||
licenseCheckResult?: LicenseCheck;
|
||||
apiResponse?: () => unknown;
|
||||
asserts: { statusCode: number; result?: Record<string, any> };
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
deprecated: { notice: 'It is deprecated, sorry.' },
|
||||
id: 'alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-alpha-all-so'],
|
||||
read: ['all-alpha-read-so'],
|
||||
},
|
||||
ui: ['all-alpha-ui'],
|
||||
app: ['all-alpha-app'],
|
||||
api: ['all-alpha-api'],
|
||||
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-alpha-all-so'],
|
||||
read: ['read-alpha-read-so'],
|
||||
},
|
||||
ui: ['read-alpha-ui'],
|
||||
app: ['read-alpha-app'],
|
||||
api: ['read-alpha-api'],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }],
|
||||
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_alpha',
|
||||
name: 'Sub Feature Alpha',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-alpha-all-so'],
|
||||
read: ['sub-alpha-read-so'],
|
||||
},
|
||||
ui: ['sub-alpha-ui'],
|
||||
app: ['sub-alpha-app'],
|
||||
api: ['sub-alpha-api'],
|
||||
replacedBy: [
|
||||
{ feature: 'beta', privileges: ['minimal_read'] },
|
||||
{ feature: 'beta', privileges: ['sub_beta'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
new KibanaFeature({
|
||||
id: 'beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-beta-all-so'],
|
||||
read: ['all-beta-read-so'],
|
||||
},
|
||||
ui: ['all-beta-ui'],
|
||||
app: ['all-beta-app'],
|
||||
api: ['all-beta-api'],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-beta-all-so'],
|
||||
read: ['read-beta-read-so'],
|
||||
},
|
||||
ui: ['read-beta-ui'],
|
||||
app: ['read-beta-app'],
|
||||
api: ['read-beta-api'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-beta',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_beta',
|
||||
name: 'Sub Feature Beta',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-beta-all-so'],
|
||||
read: ['sub-beta-read-so'],
|
||||
},
|
||||
ui: ['sub-beta-ui'],
|
||||
app: ['sub-beta-app'],
|
||||
api: ['sub-beta-api'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
describe('GET role', () => {
|
||||
const getRoleTest = (
|
||||
description: string,
|
||||
{ name, licenseCheckResult = { state: 'valid' }, apiResponse, asserts }: TestOptions
|
||||
{ name, licenseCheckResult = { state: 'valid' }, apiResponse, asserts, query }: TestOptions
|
||||
) => {
|
||||
test(description, async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
mockRouteDefinitionParams.authz.applicationName = application;
|
||||
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]);
|
||||
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features);
|
||||
mockRouteDefinitionParams.subFeaturePrivilegeIterator =
|
||||
featuresPluginMock.createSetup().subFeaturePrivilegeIterator;
|
||||
|
||||
const mockCoreContext = coreMock.createRequestHandlerContext();
|
||||
const mockLicensingContext = {
|
||||
|
@ -54,10 +172,11 @@ describe('GET role', () => {
|
|||
|
||||
const headers = { authorization: 'foo' };
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
method: 'delete',
|
||||
method: 'get',
|
||||
path: `/api/security/role/${name}`,
|
||||
params: { name },
|
||||
headers,
|
||||
query,
|
||||
});
|
||||
|
||||
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
|
@ -1158,5 +1277,76 @@ describe('GET role', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
getRoleTest(
|
||||
`preserves privileges of deprecated features as is when [replaceDeprecatedKibanaPrivileges=false]`,
|
||||
{
|
||||
name: 'first_role',
|
||||
apiResponse: () => ({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_alpha.read'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
},
|
||||
}),
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
name: 'first_role',
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ base: [], feature: { alpha: ['read'] }, spaces: ['*'] }],
|
||||
_transform_error: [],
|
||||
_unrecognized_applications: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
getRoleTest(
|
||||
`replaces privileges of deprecated features when [replaceDeprecatedKibanaPrivileges=true]`,
|
||||
{
|
||||
name: 'first_role',
|
||||
query: { replaceDeprecatedPrivileges: true },
|
||||
apiResponse: () => ({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_alpha.read'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
},
|
||||
}),
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
name: 'first_role',
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ base: [], feature: { beta: ['read', 'sub_beta'] }, spaces: ['*'] }],
|
||||
_transform_error: [],
|
||||
_unrecognized_applications: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@ export function defineGetRolesRoutes({
|
|||
router,
|
||||
authz,
|
||||
getFeatures,
|
||||
subFeaturePrivilegeIterator,
|
||||
logger,
|
||||
}: RouteDefinitionParams) {
|
||||
router.get(
|
||||
|
@ -27,6 +28,9 @@ export function defineGetRolesRoutes({
|
|||
},
|
||||
validate: {
|
||||
params: schema.object({ name: schema.string({ minLength: 1 }) }),
|
||||
query: schema.maybe(
|
||||
schema.object({ replaceDeprecatedPrivileges: schema.maybe(schema.boolean()) })
|
||||
),
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
|
@ -44,14 +48,16 @@ export function defineGetRolesRoutes({
|
|||
|
||||
if (elasticsearchRole) {
|
||||
return response.ok({
|
||||
body: transformElasticsearchRoleToRole(
|
||||
body: transformElasticsearchRoleToRole({
|
||||
features,
|
||||
// @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]`
|
||||
subFeaturePrivilegeIterator, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]`
|
||||
elasticsearchRole,
|
||||
request.params.name,
|
||||
authz.applicationName,
|
||||
logger
|
||||
),
|
||||
name: request.params.name,
|
||||
application: authz.applicationName,
|
||||
logger,
|
||||
replaceDeprecatedKibanaPrivileges:
|
||||
request.query?.replaceDeprecatedPrivileges ?? false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ import Boom from '@hapi/boom';
|
|||
|
||||
import { kibanaResponseFactory } from '@kbn/core/server';
|
||||
import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
|
||||
import { KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
|
||||
import type { LicenseCheck } from '@kbn/licensing-plugin/server';
|
||||
|
||||
import { defineGetAllRolesRoutes } from './get_all';
|
||||
|
@ -22,17 +24,133 @@ interface TestOptions {
|
|||
licenseCheckResult?: LicenseCheck;
|
||||
apiResponse?: () => unknown;
|
||||
asserts: { statusCode: number; result?: Record<string, any> };
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
deprecated: { notice: 'It is deprecated, sorry.' },
|
||||
id: 'alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-alpha-all-so'],
|
||||
read: ['all-alpha-read-so'],
|
||||
},
|
||||
ui: ['all-alpha-ui'],
|
||||
app: ['all-alpha-app'],
|
||||
api: ['all-alpha-api'],
|
||||
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-alpha-all-so'],
|
||||
read: ['read-alpha-read-so'],
|
||||
},
|
||||
ui: ['read-alpha-ui'],
|
||||
app: ['read-alpha-app'],
|
||||
api: ['read-alpha-api'],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }],
|
||||
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_alpha',
|
||||
name: 'Sub Feature Alpha',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-alpha-all-so'],
|
||||
read: ['sub-alpha-read-so'],
|
||||
},
|
||||
ui: ['sub-alpha-ui'],
|
||||
app: ['sub-alpha-app'],
|
||||
api: ['sub-alpha-api'],
|
||||
replacedBy: [
|
||||
{ feature: 'beta', privileges: ['minimal_read'] },
|
||||
{ feature: 'beta', privileges: ['sub_beta'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
new KibanaFeature({
|
||||
id: 'beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-beta-all-so'],
|
||||
read: ['all-beta-read-so'],
|
||||
},
|
||||
ui: ['all-beta-ui'],
|
||||
app: ['all-beta-app'],
|
||||
api: ['all-beta-api'],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-beta-all-so'],
|
||||
read: ['read-beta-read-so'],
|
||||
},
|
||||
ui: ['read-beta-ui'],
|
||||
app: ['read-beta-app'],
|
||||
api: ['read-beta-api'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-beta',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_beta',
|
||||
name: 'Sub Feature Beta',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-beta-all-so'],
|
||||
read: ['sub-beta-read-so'],
|
||||
},
|
||||
ui: ['sub-beta-ui'],
|
||||
app: ['sub-beta-app'],
|
||||
api: ['sub-beta-api'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
describe('GET all roles', () => {
|
||||
const getRolesTest = (
|
||||
description: string,
|
||||
{ licenseCheckResult = { state: 'valid' }, apiResponse, asserts }: TestOptions
|
||||
{ licenseCheckResult = { state: 'valid' }, apiResponse, asserts, query }: TestOptions
|
||||
) => {
|
||||
test(description, async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
mockRouteDefinitionParams.authz.applicationName = application;
|
||||
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]);
|
||||
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features);
|
||||
mockRouteDefinitionParams.subFeaturePrivilegeIterator =
|
||||
featuresPluginMock.createSetup().subFeaturePrivilegeIterator;
|
||||
|
||||
const mockCoreContext = coreMock.createRequestHandlerContext();
|
||||
const mockLicensingContext = {
|
||||
|
@ -54,9 +172,10 @@ describe('GET all roles', () => {
|
|||
|
||||
const headers = { authorization: 'foo' };
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
method: 'delete',
|
||||
method: 'get',
|
||||
path: '/api/security/role',
|
||||
headers,
|
||||
query,
|
||||
});
|
||||
|
||||
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
|
@ -1361,5 +1480,78 @@ describe('GET all roles', () => {
|
|||
],
|
||||
},
|
||||
});
|
||||
|
||||
getRolesTest(
|
||||
`preserves privileges of deprecated features as is when [replaceDeprecatedKibanaPrivileges=false]`,
|
||||
{
|
||||
apiResponse: () => ({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_alpha.read'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
},
|
||||
}),
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: [
|
||||
{
|
||||
name: 'first_role',
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ base: [], feature: { alpha: ['read'] }, spaces: ['*'] }],
|
||||
_transform_error: [],
|
||||
_unrecognized_applications: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
getRolesTest(
|
||||
`replaces privileges of deprecated features when [replaceDeprecatedKibanaPrivileges=true]`,
|
||||
{
|
||||
query: { replaceDeprecatedPrivileges: true },
|
||||
apiResponse: () => ({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_alpha.read'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
},
|
||||
}),
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: [
|
||||
{
|
||||
name: 'first_role',
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ base: [], feature: { beta: ['read', 'sub_beta'] }, spaces: ['*'] }],
|
||||
_transform_error: [],
|
||||
_unrecognized_applications: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '../..';
|
||||
import { compareRolesByName, transformElasticsearchRoleToRole } from '../../../authorization';
|
||||
import { wrapIntoCustomErrorResponse } from '../../../errors';
|
||||
|
@ -14,9 +16,9 @@ export function defineGetAllRolesRoutes({
|
|||
router,
|
||||
authz,
|
||||
getFeatures,
|
||||
subFeaturePrivilegeIterator,
|
||||
logger,
|
||||
buildFlavor,
|
||||
config,
|
||||
}: RouteDefinitionParams) {
|
||||
router.get(
|
||||
{
|
||||
|
@ -25,7 +27,13 @@ export function defineGetAllRolesRoutes({
|
|||
access: 'public',
|
||||
summary: `Get all roles`,
|
||||
},
|
||||
validate: false,
|
||||
validate: {
|
||||
request: {
|
||||
query: schema.maybe(
|
||||
schema.object({ replaceDeprecatedPrivileges: schema.maybe(schema.boolean()) })
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
try {
|
||||
|
@ -40,14 +48,16 @@ export function defineGetAllRolesRoutes({
|
|||
return response.ok({
|
||||
body: Object.entries(elasticsearchRoles)
|
||||
.map(([roleName, elasticsearchRole]) =>
|
||||
transformElasticsearchRoleToRole(
|
||||
transformElasticsearchRoleToRole({
|
||||
features,
|
||||
// @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[]
|
||||
subFeaturePrivilegeIterator, // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[]
|
||||
elasticsearchRole,
|
||||
roleName,
|
||||
authz.applicationName,
|
||||
logger
|
||||
)
|
||||
name: roleName,
|
||||
application: authz.applicationName,
|
||||
logger,
|
||||
replaceDeprecatedKibanaPrivileges:
|
||||
request.query?.replaceDeprecatedPrivileges ?? false,
|
||||
})
|
||||
)
|
||||
.filter((role) => {
|
||||
return !hideReservedRoles || !role.metadata?._reserved;
|
||||
|
|
|
@ -8,6 +8,8 @@ import Boom from '@hapi/boom';
|
|||
|
||||
import { kibanaResponseFactory } from '@kbn/core/server';
|
||||
import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
|
||||
import { KibanaFeature } from '@kbn/features-plugin/common';
|
||||
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
|
||||
import type { LicenseCheck } from '@kbn/licensing-plugin/server';
|
||||
|
||||
import { defineGetAllRolesBySpaceRoutes } from './get_all_by_space';
|
||||
|
@ -23,6 +25,119 @@ interface TestOptions {
|
|||
spaceId?: string;
|
||||
}
|
||||
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
deprecated: { notice: 'It is deprecated, sorry.' },
|
||||
id: 'alpha',
|
||||
name: 'Feature Alpha',
|
||||
app: [],
|
||||
category: { id: 'alpha', label: 'alpha' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-alpha-all-so'],
|
||||
read: ['all-alpha-read-so'],
|
||||
},
|
||||
ui: ['all-alpha-ui'],
|
||||
app: ['all-alpha-app'],
|
||||
api: ['all-alpha-api'],
|
||||
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-alpha-all-so'],
|
||||
read: ['read-alpha-read-so'],
|
||||
},
|
||||
ui: ['read-alpha-ui'],
|
||||
app: ['read-alpha-app'],
|
||||
api: ['read-alpha-api'],
|
||||
replacedBy: {
|
||||
default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }],
|
||||
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-alpha',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_alpha',
|
||||
name: 'Sub Feature Alpha',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-alpha-all-so'],
|
||||
read: ['sub-alpha-read-so'],
|
||||
},
|
||||
ui: ['sub-alpha-ui'],
|
||||
app: ['sub-alpha-app'],
|
||||
api: ['sub-alpha-api'],
|
||||
replacedBy: [
|
||||
{ feature: 'beta', privileges: ['minimal_read'] },
|
||||
{ feature: 'beta', privileges: ['sub_beta'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
new KibanaFeature({
|
||||
id: 'beta',
|
||||
name: 'Feature Beta',
|
||||
app: [],
|
||||
category: { id: 'beta', label: 'beta' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: ['all-beta-all-so'],
|
||||
read: ['all-beta-read-so'],
|
||||
},
|
||||
ui: ['all-beta-ui'],
|
||||
app: ['all-beta-app'],
|
||||
api: ['all-beta-api'],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: ['read-beta-all-so'],
|
||||
read: ['read-beta-read-so'],
|
||||
},
|
||||
ui: ['read-beta-ui'],
|
||||
app: ['read-beta-app'],
|
||||
api: ['read-beta-api'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub-feature-beta',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_beta',
|
||||
name: 'Sub Feature Beta',
|
||||
includeIn: 'all',
|
||||
savedObject: {
|
||||
all: ['sub-beta-all-so'],
|
||||
read: ['sub-beta-read-so'],
|
||||
},
|
||||
ui: ['sub-beta-ui'],
|
||||
app: ['sub-beta-app'],
|
||||
api: ['sub-beta-api'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
describe('GET all roles by space id', () => {
|
||||
it('correctly defines route.', () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
|
@ -50,7 +165,9 @@ describe('GET all roles by space id', () => {
|
|||
test(description, async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
mockRouteDefinitionParams.authz.applicationName = application;
|
||||
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]);
|
||||
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features);
|
||||
mockRouteDefinitionParams.subFeaturePrivilegeIterator =
|
||||
featuresPluginMock.createSetup().subFeaturePrivilegeIterator;
|
||||
|
||||
const mockCoreContext = coreMock.createRequestHandlerContext();
|
||||
const mockLicensingContext = {
|
||||
|
@ -397,4 +514,37 @@ describe('GET all roles by space id', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
getRolesTest(`replaces privileges of deprecated features by default`, {
|
||||
apiResponse: () => ({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application,
|
||||
privileges: ['feature_alpha.read'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
},
|
||||
}),
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: [
|
||||
{
|
||||
name: 'first_role',
|
||||
metadata: { _reserved: true },
|
||||
transient_metadata: { enabled: true },
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ base: [], feature: { beta: ['read', 'sub_beta'] }, spaces: ['*'] }],
|
||||
_transform_error: [],
|
||||
_unrecognized_applications: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,7 +19,7 @@ export function defineGetAllRolesBySpaceRoutes({
|
|||
getFeatures,
|
||||
logger,
|
||||
buildFlavor,
|
||||
config,
|
||||
subFeaturePrivilegeIterator,
|
||||
}: RouteDefinitionParams) {
|
||||
router.get(
|
||||
{
|
||||
|
@ -49,14 +49,17 @@ export function defineGetAllRolesBySpaceRoutes({
|
|||
return acc;
|
||||
}
|
||||
|
||||
const role = transformElasticsearchRoleToRole(
|
||||
const role = transformElasticsearchRoleToRole({
|
||||
features,
|
||||
// @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[]
|
||||
elasticsearchRole,
|
||||
roleName,
|
||||
authz.applicationName,
|
||||
logger
|
||||
);
|
||||
name: roleName,
|
||||
application: authz.applicationName,
|
||||
logger,
|
||||
subFeaturePrivilegeIterator,
|
||||
// For the internal APIs we always transform deprecated privileges.
|
||||
replaceDeprecatedKibanaPrivileges: true,
|
||||
});
|
||||
|
||||
const includeRoleForSpace = role.kibana.some((privilege) => {
|
||||
const privilegeInSpace =
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { Observable } from 'rxjs';
|
|||
import type { BuildFlavor } from '@kbn/config/src/types';
|
||||
import type { HttpResources, IBasePath, Logger } from '@kbn/core/server';
|
||||
import type { KibanaFeature } from '@kbn/features-plugin/server';
|
||||
import type { SubFeaturePrivilegeIterator } from '@kbn/features-plugin/server/feature_privilege_iterator';
|
||||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
|
||||
import { defineAnalyticsRoutes } from './analytics';
|
||||
|
@ -51,6 +52,7 @@ export interface RouteDefinitionParams {
|
|||
getSession: () => PublicMethodsOf<Session>;
|
||||
license: SecurityLicense;
|
||||
getFeatures: () => Promise<KibanaFeature[]>;
|
||||
subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator;
|
||||
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
|
||||
getAuthenticationService: () => InternalAuthenticationServiceStart;
|
||||
getUserProfileService: () => UserProfileServiceStartInternal;
|
||||
|
|
|
@ -85,6 +85,7 @@
|
|||
"@kbn/security-authorization-core",
|
||||
"@kbn/security-role-management-model",
|
||||
"@kbn/security-ui-components",
|
||||
"@kbn/security-authorization-core-common",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -31,8 +31,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { type RawKibanaPrivileges } from '@kbn/security-authorization-core';
|
||||
import type { Role, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common';
|
||||
import type {
|
||||
RawKibanaPrivileges,
|
||||
Role,
|
||||
RoleKibanaPrivilege,
|
||||
} from '@kbn/security-plugin-types-common';
|
||||
import type { BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public/src/roles/roles_api_client';
|
||||
import { KibanaPrivileges } from '@kbn/security-role-management-model';
|
||||
import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components';
|
||||
|
|
|
@ -48,7 +48,6 @@
|
|||
"@kbn/react-kibana-mount",
|
||||
"@kbn/shared-ux-utility",
|
||||
"@kbn/core-application-common",
|
||||
"@kbn/security-authorization-core",
|
||||
"@kbn/core-notifications-browser",
|
||||
"@kbn/logging",
|
||||
"@kbn/core-logging-browser-mocks",
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import util from 'util';
|
||||
import { isEqual, isEqualWith } from 'lodash';
|
||||
import expect from '@kbn/expect';
|
||||
import { RawKibanaPrivileges } from '@kbn/security-plugin/common/model';
|
||||
import { RawKibanaPrivileges } from '@kbn/security-plugin-types-common';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
|
@ -112,7 +112,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedQueryManagement: ['all', 'minimal_all'],
|
||||
savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
osquery: [
|
||||
'all',
|
||||
'read',
|
||||
|
|
|
@ -27,7 +27,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedQueryManagement: ['all', 'minimal_all'],
|
||||
savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
graph: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
|
@ -201,7 +201,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedQueryManagement: ['all', 'minimal_all'],
|
||||
savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
osquery: [
|
||||
'all',
|
||||
'read',
|
||||
|
|
38
x-pack/test/security_api_integration/features.config.ts
Normal file
38
x-pack/test/security_api_integration/features.config.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { resolve } from 'path';
|
||||
|
||||
import type { FtrConfigProviderContext } from '@kbn/test';
|
||||
|
||||
import { services } from './services';
|
||||
|
||||
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
|
||||
|
||||
const featuresProviderPlugin = resolve(__dirname, './plugins/features_provider');
|
||||
|
||||
return {
|
||||
testFiles: [require.resolve('./tests/features')],
|
||||
servers: xPackAPITestsConfig.get('servers'),
|
||||
security: { disableTestUser: true },
|
||||
services,
|
||||
junit: {
|
||||
reportName: 'X-Pack Security API Integration Tests (Features)',
|
||||
},
|
||||
|
||||
esTestCluster: xPackAPITestsConfig.get('esTestCluster'),
|
||||
|
||||
kbnTestServer: {
|
||||
...xPackAPITestsConfig.get('kbnTestServer'),
|
||||
serverArgs: [
|
||||
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
|
||||
`--plugin-path=${featuresProviderPlugin}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/features-provider-plugin",
|
||||
"owner": "@elastic/kibana-security",
|
||||
"plugin": {
|
||||
"id": "featuresProviderPlugin",
|
||||
"server": true,
|
||||
"browser": false,
|
||||
"requiredPlugins": [
|
||||
"alerting",
|
||||
"features"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,375 @@
|
|||
/*
|
||||
* 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 { PluginSetupContract as AlertingPluginsSetup } from '@kbn/alerting-plugin/server/plugin';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { CoreSetup, Plugin, PluginInitializer } from '@kbn/core/server';
|
||||
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
|
||||
import type { FeaturesPluginSetup, FeaturesPluginStart } from '@kbn/features-plugin/server';
|
||||
|
||||
export interface PluginSetupDependencies {
|
||||
features: FeaturesPluginSetup;
|
||||
alerting: AlertingPluginsSetup;
|
||||
}
|
||||
|
||||
export interface PluginStartDependencies {
|
||||
features: FeaturesPluginStart;
|
||||
}
|
||||
|
||||
export const plugin: PluginInitializer<void, void> = async (): Promise<
|
||||
Plugin<void, void, PluginSetupDependencies, PluginStartDependencies>
|
||||
> => ({
|
||||
setup: (_: 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);
|
||||
|
||||
// Case #2: feature A needs to be split into two separate features B and C. In this case we mark
|
||||
// feature as deprecated and create two new features.
|
||||
case2FeatureSplit(deps);
|
||||
|
||||
// Case #3: feature A grants access to Saved Object types `one` and `two` via top-level `all`
|
||||
// and `read` privileges. The requirement is to not grant access to `two` via top-level
|
||||
// privileges, and instead use sub-feature privilege for that.
|
||||
case3FeatureSplitSubFeature(deps);
|
||||
|
||||
// Case #4: features A (`case_4_feature_a`) and B (`case_4_feature_b`) grant access to the saved object type `ab`.
|
||||
// The requirement is to introduce a new feature C (`case_4_feature_c) that will grant access to `ab`, and remove
|
||||
// this privilege from feature A and B. Here's what we'll have as the result:
|
||||
// * `case_4_feature_a` (existing, deprecated)
|
||||
// * `case_4_feature_b` (existing, deprecated)
|
||||
// * `case_4_feature_a_v2` (new, decoupled from `ab` SO, partially replaces `case_4_feature_a`)
|
||||
// * `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);
|
||||
},
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
function case1FeatureRename(deps: PluginSetupDependencies) {
|
||||
// Step 1: extract a part of the feature definition that will be shared between deprecated and new
|
||||
// features.
|
||||
const commonFeatureDefinition = {
|
||||
app: [],
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
privileges: {
|
||||
all: { savedObject: { all: ['one'], read: [] }, ui: ['ui_all'] },
|
||||
read: { savedObject: { all: [], read: ['one'] }, ui: ['ui_read'] },
|
||||
},
|
||||
};
|
||||
|
||||
// Step 2: mark feature A as deprecated and provide proper replacements for all feature and
|
||||
// sub-feature privileges.
|
||||
deps.features.registerKibanaFeature({
|
||||
...commonFeatureDefinition,
|
||||
deprecated: { notice: 'Case #1 is deprecated.' },
|
||||
id: 'case_1_feature_a',
|
||||
name: 'Case #1 feature A (DEPRECATED)',
|
||||
privileges: {
|
||||
all: {
|
||||
...commonFeatureDefinition.privileges.all,
|
||||
replacedBy: [{ feature: 'case_1_feature_b', privileges: ['all'] }],
|
||||
},
|
||||
read: {
|
||||
...commonFeatureDefinition.privileges.read,
|
||||
replacedBy: [{ feature: 'case_1_feature_b', privileges: ['read'] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Step 3: define a new feature with exactly same privileges.
|
||||
deps.features.registerKibanaFeature({
|
||||
...commonFeatureDefinition,
|
||||
id: 'case_1_feature_b',
|
||||
name: 'Case #1 feature B',
|
||||
});
|
||||
}
|
||||
|
||||
function case2FeatureSplit(deps: PluginSetupDependencies) {
|
||||
// Step 1: mark feature A as deprecated and provide proper replacements for all feature and
|
||||
// sub-feature privileges.
|
||||
deps.features.registerKibanaFeature({
|
||||
deprecated: { notice: 'Case #2 is deprecated.' },
|
||||
|
||||
app: ['app_one', 'app_two'],
|
||||
catalogue: ['cat_one', 'cat_two'],
|
||||
management: { kibana: ['management_one', 'management_two'] },
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: 'case_2_feature_a',
|
||||
name: 'Case #2 feature A (DEPRECATED)',
|
||||
alerting: ['alerting_rule_type_one', 'alerting_rule_type_two'],
|
||||
cases: ['cases_owner_one', 'cases_owner_two'],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: ['one', 'two'], read: [] },
|
||||
ui: ['ui_all_one', 'ui_all_two'],
|
||||
api: ['api_one', 'api_two'],
|
||||
app: ['app_one', 'app_two'],
|
||||
catalogue: ['cat_one', 'cat_two'],
|
||||
management: { kibana: ['management_one', 'management_two'] },
|
||||
alerting: {
|
||||
rule: {
|
||||
all: ['alerting_rule_type_one', 'alerting_rule_type_two'],
|
||||
read: ['alerting_rule_type_one', 'alerting_rule_type_two'],
|
||||
},
|
||||
alert: {
|
||||
all: ['alerting_rule_type_one', 'alerting_rule_type_two'],
|
||||
read: ['alerting_rule_type_one', 'alerting_rule_type_two'],
|
||||
},
|
||||
},
|
||||
cases: {
|
||||
all: ['cases_owner_one', 'cases_owner_two'],
|
||||
push: ['cases_owner_one', 'cases_owner_two'],
|
||||
create: ['cases_owner_one', 'cases_owner_two'],
|
||||
read: ['cases_owner_one', 'cases_owner_two'],
|
||||
update: ['cases_owner_one', 'cases_owner_two'],
|
||||
delete: ['cases_owner_one', 'cases_owner_two'],
|
||||
settings: ['cases_owner_one', 'cases_owner_two'],
|
||||
},
|
||||
replacedBy: [
|
||||
{ feature: 'case_2_feature_b', privileges: ['all'] },
|
||||
{ feature: 'case_2_feature_c', privileges: ['all'] },
|
||||
],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: ['one', 'two'] },
|
||||
ui: ['ui_read_one', 'ui_read_two'],
|
||||
replacedBy: [
|
||||
{ feature: 'case_2_feature_b', privileges: ['read'] },
|
||||
{ feature: 'case_2_feature_c', privileges: ['read'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: define new features
|
||||
deps.features.registerKibanaFeature({
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: 'case_2_feature_b',
|
||||
name: 'Case #2 feature B',
|
||||
app: ['app_one'],
|
||||
catalogue: ['cat_one'],
|
||||
management: { kibana: ['management_one'] },
|
||||
alerting: ['alerting_rule_type_one'],
|
||||
cases: ['cases_owner_one'],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: ['one'], read: [] },
|
||||
ui: ['ui_all_one'],
|
||||
api: ['api_one'],
|
||||
app: ['app_one'],
|
||||
catalogue: ['cat_one'],
|
||||
management: { kibana: ['management_one'] },
|
||||
alerting: {
|
||||
rule: { all: ['alerting_rule_type_one'], read: ['alerting_rule_type_one'] },
|
||||
alert: { all: ['alerting_rule_type_one'], read: ['alerting_rule_type_one'] },
|
||||
},
|
||||
cases: {
|
||||
all: ['cases_owner_one'],
|
||||
push: ['cases_owner_one'],
|
||||
create: ['cases_owner_one'],
|
||||
read: ['cases_owner_one'],
|
||||
update: ['cases_owner_one'],
|
||||
delete: ['cases_owner_one'],
|
||||
settings: ['cases_owner_one'],
|
||||
},
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: ['one'] },
|
||||
ui: ['ui_read_one'],
|
||||
},
|
||||
},
|
||||
});
|
||||
deps.features.registerKibanaFeature({
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: 'case_2_feature_c',
|
||||
name: 'Case #2 feature C',
|
||||
app: ['app_two'],
|
||||
catalogue: ['cat_two'],
|
||||
management: { kibana: ['management_two'] },
|
||||
alerting: ['alerting_rule_type_two'],
|
||||
cases: ['cases_owner_two'],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: ['two'], read: [] },
|
||||
ui: ['ui_all_two'],
|
||||
api: ['api_two'],
|
||||
app: ['app_two'],
|
||||
catalogue: ['cat_two'],
|
||||
management: { kibana: ['management_two'] },
|
||||
alerting: {
|
||||
rule: { all: ['alerting_rule_type_two'], read: ['alerting_rule_type_two'] },
|
||||
alert: { all: ['alerting_rule_type_two'], read: ['alerting_rule_type_two'] },
|
||||
},
|
||||
cases: {
|
||||
all: ['cases_owner_two'],
|
||||
push: ['cases_owner_two'],
|
||||
create: ['cases_owner_two'],
|
||||
read: ['cases_owner_two'],
|
||||
update: ['cases_owner_two'],
|
||||
delete: ['cases_owner_two'],
|
||||
settings: ['cases_owner_two'],
|
||||
},
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: ['two'] },
|
||||
ui: ['ui_read_two'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Register alerting rule types used in a deprecated feature.
|
||||
for (const [id, producer] of [
|
||||
['alerting_rule_type_one', 'case_2_feature_a'],
|
||||
['alerting_rule_type_two', 'case_2_feature_a'],
|
||||
]) {
|
||||
deps.alerting.registerType({
|
||||
id,
|
||||
name: `${id}-${producer} name`,
|
||||
actionGroups: [{ id: 'default', name: 'Default' }],
|
||||
category: 'kibana',
|
||||
producer,
|
||||
defaultActionGroupId: 'default',
|
||||
minimumLicenseRequired: 'basic',
|
||||
isExportable: true,
|
||||
executor: () => Promise.resolve({ state: {} }),
|
||||
validate: { params: schema.any() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function case3FeatureSplitSubFeature(deps: PluginSetupDependencies) {
|
||||
// Step 1: mark feature A as deprecated and provide proper replacements for all feature and
|
||||
// sub-feature privileges.
|
||||
deps.features.registerKibanaFeature({
|
||||
deprecated: { notice: 'Case #3 is deprecated.' },
|
||||
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: 'case_3_feature_a',
|
||||
name: 'Case #3 feature A (DEPRECATED)',
|
||||
app: [],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: ['one', 'two'], read: [] },
|
||||
ui: [],
|
||||
// Since `case_3_feature_a_v2.so_two_all` isn't automatically included in `case_3_feature_a_v2.all`,
|
||||
// we should map to both minimal `all` privilege and sub-feature privilege.
|
||||
replacedBy: [{ feature: 'case_3_feature_a_v2', privileges: ['minimal_all', 'so_two_all'] }],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: ['one', 'two'] },
|
||||
ui: [],
|
||||
replacedBy: [
|
||||
// Since `case_3_feature_a_v2.so_two_read` isn't automatically included in `case_3_feature_a_v2.read`,
|
||||
// we should map to both minimal `read` privilege and sub-feature privilege.
|
||||
{ feature: 'case_3_feature_a_v2', privileges: ['minimal_read', 'so_two_read'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: Create a new feature with the desired privileges structure.
|
||||
deps.features.registerKibanaFeature({
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: 'case_3_feature_a_v2',
|
||||
name: 'Case #3 feature A',
|
||||
app: [],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: ['one'], read: [] },
|
||||
ui: [],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: ['one'] },
|
||||
ui: [],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'Access to SO `two`',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
id: 'so_two_all',
|
||||
includeIn: 'none',
|
||||
name: 'All',
|
||||
savedObject: { all: ['two'], read: [] },
|
||||
ui: [],
|
||||
},
|
||||
{
|
||||
id: 'so_two_read',
|
||||
includeIn: 'none',
|
||||
name: 'Read',
|
||||
savedObject: { all: [], read: ['two'] },
|
||||
ui: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function case4FeatureExtract(deps: PluginSetupDependencies) {
|
||||
for (const suffix of ['A', 'B']) {
|
||||
// Step 1: mark existing feature A and feature B as deprecated.
|
||||
deps.features.registerKibanaFeature({
|
||||
deprecated: { notice: 'Case #4 is deprecated.' },
|
||||
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: `case_4_feature_${suffix.toLowerCase()}`,
|
||||
name: `Case #4 feature ${suffix} (DEPRECATED)`,
|
||||
app: [],
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: { all: ['ab'], read: [] },
|
||||
ui: ['ui_all'],
|
||||
replacedBy: [
|
||||
{ feature: `case_4_feature_${suffix.toLowerCase()}_v2`, privileges: ['all'] },
|
||||
{ feature: `case_4_feature_c`, privileges: ['all'] },
|
||||
],
|
||||
},
|
||||
read: {
|
||||
savedObject: { all: [], read: ['ab'] },
|
||||
ui: ['ui_read'],
|
||||
replacedBy: [
|
||||
{ feature: `case_4_feature_${suffix.toLowerCase()}_v2`, privileges: ['read'] },
|
||||
{ feature: `case_4_feature_c`, privileges: ['all'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: introduce new features (v2) with privileges that don't grant access to `ab`.
|
||||
deps.features.registerKibanaFeature({
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: `case_4_feature_${suffix.toLowerCase()}_v2`,
|
||||
name: `Case #4 feature ${suffix}`,
|
||||
app: [],
|
||||
privileges: {
|
||||
all: { savedObject: { all: [], read: [] }, ui: ['ui_all'] },
|
||||
read: { savedObject: { all: [], read: [] }, ui: ['ui_read'] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: introduce new feature C that only grants access to `ab`.
|
||||
deps.features.registerKibanaFeature({
|
||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
id: 'case_4_feature_c',
|
||||
name: 'Case #4 feature C',
|
||||
app: [],
|
||||
privileges: {
|
||||
all: { savedObject: { all: ['ab'], read: [] }, ui: ['ui_all'] },
|
||||
read: { savedObject: { all: [], read: ['ab'] }, ui: ['ui_read'] },
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/features-plugin",
|
||||
"@kbn/alerting-plugin",
|
||||
"@kbn/config-schema",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,453 @@
|
|||
/*
|
||||
* 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 { expect } from 'expect';
|
||||
|
||||
import type { Case, CasePostRequest } from '@kbn/cases-plugin/common';
|
||||
import { CaseSeverity, ConnectorTypes } from '@kbn/cases-plugin/common';
|
||||
import type { CasesFindResponse } from '@kbn/cases-plugin/common/types/api';
|
||||
import type {
|
||||
FeatureKibanaPrivilegesReference,
|
||||
KibanaFeatureConfig,
|
||||
} from '@kbn/features-plugin/common';
|
||||
import type { Role } from '@kbn/security-plugin-types-common';
|
||||
|
||||
import type { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
function collectReplacedByForFeaturePrivileges(
|
||||
feature: KibanaFeatureConfig
|
||||
): Array<[string, readonly FeatureKibanaPrivilegesReference[]]> {
|
||||
const privilegesToReplace = [] as Array<[string, readonly FeatureKibanaPrivilegesReference[]]>;
|
||||
if (feature.privileges) {
|
||||
const allReplacedBy = feature.privileges.all.replacedBy ?? [];
|
||||
const readReplacedBy = feature.privileges.read.replacedBy ?? [];
|
||||
privilegesToReplace.push([
|
||||
'all',
|
||||
'default' in allReplacedBy ? allReplacedBy.default : allReplacedBy,
|
||||
]);
|
||||
privilegesToReplace.push([
|
||||
'minimal_all',
|
||||
'minimal' in allReplacedBy ? allReplacedBy.minimal : allReplacedBy,
|
||||
]);
|
||||
privilegesToReplace.push([
|
||||
'read',
|
||||
'default' in readReplacedBy ? readReplacedBy.default : readReplacedBy,
|
||||
]);
|
||||
privilegesToReplace.push([
|
||||
'minimal_read',
|
||||
'minimal' in readReplacedBy ? readReplacedBy.minimal : readReplacedBy,
|
||||
]);
|
||||
}
|
||||
|
||||
for (const subFeature of feature.subFeatures ?? []) {
|
||||
for (const group of subFeature.privilegeGroups) {
|
||||
for (const subFeaturePrivilege of group.privileges) {
|
||||
privilegesToReplace.push([subFeaturePrivilege.id, subFeaturePrivilege.replacedBy ?? []]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return privilegesToReplace;
|
||||
}
|
||||
|
||||
function getActionsToReplace(actions: string[]) {
|
||||
// The `alerting:`-prefixed actions are special since they are prefixed with a feature ID, and do
|
||||
// not need to be replaced like any other privileges.
|
||||
return actions.filter((action) => !action.startsWith('alerting:'));
|
||||
}
|
||||
|
||||
function getUserCredentials(username: string) {
|
||||
return `Basic ${Buffer.from(`${username}:changeme`).toString('base64')}`;
|
||||
}
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
describe('deprecated features', function () {
|
||||
const supertest = getService('supertest');
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
const log = getService('log');
|
||||
const security = getService('security');
|
||||
|
||||
before(async () => {
|
||||
// Create role with deprecated feature privilege.
|
||||
await security.role.create('case_2_a_deprecated', {
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ spaces: ['*'], base: [], feature: { case_2_feature_a: ['all'] } }],
|
||||
});
|
||||
|
||||
// Fetch the _transformed_ deprecated role and use it to create a new role.
|
||||
const { elasticsearch, kibana } = (await security.role.get('case_2_a_deprecated', {
|
||||
replaceDeprecatedPrivileges: true,
|
||||
})) as Role;
|
||||
expect(kibana).toEqual([
|
||||
{
|
||||
spaces: ['*'],
|
||||
base: [],
|
||||
feature: { case_2_feature_b: ['all'], case_2_feature_c: ['all'] },
|
||||
},
|
||||
]);
|
||||
await security.role.create('case_2_a_transformed', { elasticsearch, kibana });
|
||||
|
||||
// Create roles with the privileges that are supposed to replace deprecated privilege.
|
||||
await security.role.create('case_2_b_new', {
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ spaces: ['*'], base: [], feature: { case_2_feature_b: ['all'] } }],
|
||||
});
|
||||
await security.role.create('case_2_c_new', {
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [{ spaces: ['*'], base: [], feature: { case_2_feature_c: ['all'] } }],
|
||||
});
|
||||
|
||||
await security.user.create('case_2_a_deprecated', {
|
||||
password: 'changeme',
|
||||
roles: ['case_2_a_deprecated'],
|
||||
full_name: 'Deprecated',
|
||||
});
|
||||
|
||||
await security.user.create('case_2_a_transformed', {
|
||||
password: 'changeme',
|
||||
roles: ['case_2_a_transformed'],
|
||||
full_name: 'Transformed',
|
||||
});
|
||||
|
||||
await security.user.create('case_2_b_new', {
|
||||
password: 'changeme',
|
||||
roles: ['case_2_b_new'],
|
||||
full_name: 'New B',
|
||||
});
|
||||
|
||||
await security.user.create('case_2_c_new', {
|
||||
password: 'changeme',
|
||||
roles: ['case_2_c_new'],
|
||||
full_name: 'New C',
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Cleanup roles and users.
|
||||
await Promise.all([
|
||||
security.role.delete('case_2_a_deprecated'),
|
||||
security.role.delete('case_2_a_transformed'),
|
||||
security.role.delete('case_2_b_new'),
|
||||
security.role.delete('case_2_c_new'),
|
||||
security.user.delete('case_2_a_deprecated'),
|
||||
security.user.delete('case_2_a_transformed'),
|
||||
security.user.delete('case_2_b_new'),
|
||||
security.user.delete('case_2_c_new'),
|
||||
]);
|
||||
|
||||
// Cleanup cases.
|
||||
const { body: cases } = await supertest
|
||||
.get(`/api/cases/_find`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(200);
|
||||
const casesIds = (cases as CasesFindResponse).cases.map((c) => c.id);
|
||||
if (casesIds.length > 0) {
|
||||
await supertest
|
||||
.delete(`/api/cases`)
|
||||
// we need to json stringify here because just passing in the array of case IDs will cause a 400 with Kibana
|
||||
// not being able to parse the array correctly. The format ids=["1", "2"] seems to work, which stringify outputs.
|
||||
.query({ ids: JSON.stringify(casesIds) })
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send()
|
||||
.expect(204);
|
||||
}
|
||||
|
||||
// Cleanup alerting rules.
|
||||
const { body: rules } = await supertest.get(`/api/alerting/rules/_find`).expect(200);
|
||||
for (const rule of rules.data) {
|
||||
await supertest.delete(`/api/alerting/rule/${rule.id}`).set('kbn-xsrf', 'true').expect(204);
|
||||
}
|
||||
});
|
||||
|
||||
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 features = featuresResponse.body as KibanaFeatureConfig[];
|
||||
|
||||
// Collect all deprecated features.
|
||||
const deprecatedFeatures = features.filter((f) => f.deprecated);
|
||||
log.info(`Found ${deprecatedFeatures.length} deprecated features.`);
|
||||
|
||||
// Fetch all feature privileges registered as Elasticsearch application privileges.
|
||||
const privilegesResponse = await supertest
|
||||
.get('/api/security/privileges?includeActions=true')
|
||||
.expect(200);
|
||||
const featurePrivilegesAndActions = privilegesResponse.body.features as Record<
|
||||
string,
|
||||
Record<string, string[]>
|
||||
>;
|
||||
|
||||
// Ensure that all deprecated features registered their privileges as Elasticsearch application privileges.
|
||||
for (const feature of deprecatedFeatures) {
|
||||
const privilegeReplacedBy = collectReplacedByForFeaturePrivileges(feature);
|
||||
for (const [privilegeId, replacedBy] of privilegeReplacedBy) {
|
||||
log.debug(
|
||||
`Verifying that deprecated "${feature.id}" feature has registered "${privilegeId}" privilege in Elasticsearch.`
|
||||
);
|
||||
|
||||
// Capture all actions from the deprecated feature that need to be replaced.
|
||||
const deprecatedActions = getActionsToReplace(
|
||||
featurePrivilegesAndActions[feature.id]?.[privilegeId] ?? []
|
||||
);
|
||||
|
||||
// Capture all actions that will replace the deprecated actions.
|
||||
const replacementActions = new Set(
|
||||
replacedBy.flatMap(({ feature: featureId, privileges }) =>
|
||||
privileges.flatMap((privilege) =>
|
||||
getActionsToReplace(featurePrivilegesAndActions[featureId]?.[privilege] ?? [])
|
||||
)
|
||||
)
|
||||
);
|
||||
log.debug(
|
||||
`Privilege "${privilegeId}" of the deprecated feature "${feature.id}" has ${deprecatedActions.length} actions that will be replaced with ${replacementActions.size} actions.`
|
||||
);
|
||||
|
||||
for (const deprecatedAction of deprecatedActions) {
|
||||
if (!replacementActions.has(deprecatedAction)) {
|
||||
throw new Error(
|
||||
`Action "${deprecatedAction}" granted by the privilege "${privilegeId}" of the deprecated feature "${feature.id}" is not properly replaced.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('replaced UI actions are properly set for deprecated privileges', async () => {
|
||||
const { body: capabilities } = await supertestWithoutAuth
|
||||
.post('/api/core/capabilities')
|
||||
.set('Authorization', getUserCredentials('case_2_a_deprecated'))
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ applications: [] })
|
||||
.expect(200);
|
||||
|
||||
// Both deprecated and new UI capabilities should be toggled.
|
||||
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 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('Cases privileges are properly handled for deprecated privileges', async () => {
|
||||
const createCase = async (
|
||||
authorization: string,
|
||||
props: Partial<CasePostRequest> = {}
|
||||
): Promise<Case> => {
|
||||
const caseRequest: CasePostRequest = {
|
||||
description: 'This is a case created by a user with deprecated privilege.',
|
||||
title: 'case_2_a_deprecated',
|
||||
tags: ['defacement'],
|
||||
severity: CaseSeverity.LOW,
|
||||
connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null },
|
||||
settings: { syncAlerts: true },
|
||||
owner: 'cases_owner_one',
|
||||
assignees: [],
|
||||
...props,
|
||||
};
|
||||
|
||||
const { body: newCase } = await supertestWithoutAuth
|
||||
.post('/api/cases')
|
||||
.set('Authorization', authorization)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send(caseRequest)
|
||||
.expect(200);
|
||||
return newCase;
|
||||
};
|
||||
|
||||
const getCase = async (authorization: string, caseId: string): Promise<Case | undefined> => {
|
||||
const { body } = await supertestWithoutAuth
|
||||
.get(`/api/cases/_find`)
|
||||
.set('Authorization', authorization)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(200);
|
||||
return (body as CasesFindResponse).cases.find((c) => c.id === caseId);
|
||||
};
|
||||
|
||||
// Create cases as user with deprecated privilege.
|
||||
const deprecatedUser = getUserCredentials('case_2_a_deprecated');
|
||||
const caseOneDeprecated = await createCase(deprecatedUser, {
|
||||
title: 'case_2_a_deprecated_one',
|
||||
owner: 'cases_owner_one',
|
||||
});
|
||||
const caseTwoDeprecated = await createCase(deprecatedUser, {
|
||||
title: 'case_2_a_deprecated_two',
|
||||
owner: 'cases_owner_two',
|
||||
});
|
||||
|
||||
// Create cases as user with transformed privileges (should be able to create cases for both
|
||||
// owners).
|
||||
const transformedUser = getUserCredentials('case_2_a_transformed');
|
||||
const caseOneTransformed = await createCase(transformedUser, {
|
||||
title: 'case_2_a_transformed_one',
|
||||
owner: 'cases_owner_one',
|
||||
});
|
||||
const caseTwoTransformed = await createCase(transformedUser, {
|
||||
title: 'case_2_a_transformed_two',
|
||||
owner: 'cases_owner_two',
|
||||
});
|
||||
|
||||
// Create cases as user with new privileges (B).
|
||||
const newUserB = getUserCredentials('case_2_b_new');
|
||||
const caseOneNewB = await createCase(newUserB, {
|
||||
title: 'case_2_b_new_one',
|
||||
owner: 'cases_owner_one',
|
||||
});
|
||||
|
||||
// Create cases as user with new privileges (C).
|
||||
const newUserC = getUserCredentials('case_2_c_new');
|
||||
const caseTwoNewC = await createCase(newUserC, {
|
||||
title: 'case_2_c_new_two',
|
||||
owner: 'cases_owner_two',
|
||||
});
|
||||
|
||||
// Users with deprecated and transformed privileges should have the same privilege level and
|
||||
// be able to access cases created by themselves and users with new privileges.
|
||||
for (const caseToCheck of [
|
||||
caseOneDeprecated,
|
||||
caseTwoDeprecated,
|
||||
caseOneTransformed,
|
||||
caseTwoTransformed,
|
||||
caseOneNewB,
|
||||
caseTwoNewC,
|
||||
]) {
|
||||
expect(await getCase(deprecatedUser, caseToCheck.id)).toBeDefined();
|
||||
expect(await getCase(transformedUser, caseToCheck.id)).toBeDefined();
|
||||
}
|
||||
|
||||
// User B and User C should be able to access cases created by themselves and users with
|
||||
// deprecated and transformed privileges, but only for the specific owner.
|
||||
for (const caseToCheck of [caseOneDeprecated, caseOneTransformed, caseOneNewB]) {
|
||||
expect(await getCase(newUserB, caseToCheck.id)).toBeDefined();
|
||||
expect(await getCase(newUserC, caseToCheck.id)).toBeUndefined();
|
||||
}
|
||||
for (const caseToCheck of [caseTwoDeprecated, caseTwoTransformed, caseTwoNewC]) {
|
||||
expect(await getCase(newUserC, caseToCheck.id)).toBeDefined();
|
||||
expect(await getCase(newUserB, caseToCheck.id)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Alerting privileges are properly handled for deprecated privileges', async () => {
|
||||
const createRule = async (
|
||||
authorization: string,
|
||||
name: string,
|
||||
consumer: string,
|
||||
ruleType: string
|
||||
): Promise<{ id: string }> => {
|
||||
const { body: newRule } = await supertestWithoutAuth
|
||||
.post('/api/alerting/rule')
|
||||
.set('Authorization', authorization)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
enabled: true,
|
||||
name,
|
||||
tags: ['foo'],
|
||||
rule_type_id: ruleType,
|
||||
consumer,
|
||||
schedule: { interval: '24h' },
|
||||
throttle: undefined,
|
||||
notify_when: undefined,
|
||||
actions: [],
|
||||
params: {},
|
||||
})
|
||||
.expect(200);
|
||||
return newRule;
|
||||
};
|
||||
|
||||
const getRule = async (authorization: string, ruleId: string): Promise<unknown> => {
|
||||
const { body } = await supertestWithoutAuth
|
||||
.get(`/api/alerting/rules/_find`)
|
||||
.set('Authorization', authorization)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(200);
|
||||
return body.data.find((r: { id: string }) => r.id === ruleId);
|
||||
};
|
||||
|
||||
// Create rules as user with deprecated privilege.
|
||||
const deprecatedUser = getUserCredentials('case_2_a_deprecated');
|
||||
const ruleOneDeprecated = await createRule(
|
||||
deprecatedUser,
|
||||
'case_2_a_deprecated_one',
|
||||
'case_2_feature_a',
|
||||
'alerting_rule_type_one'
|
||||
);
|
||||
const ruleTwoDeprecated = await createRule(
|
||||
deprecatedUser,
|
||||
'case_2_a_deprecated_two',
|
||||
'case_2_feature_a',
|
||||
'alerting_rule_type_two'
|
||||
);
|
||||
|
||||
// Create rules as user with transformed privileges (should be able to create rules for both
|
||||
// owners).
|
||||
const transformedUser = getUserCredentials('case_2_a_transformed');
|
||||
const ruleOneTransformed = await createRule(
|
||||
transformedUser,
|
||||
'case_2_a_transform_one',
|
||||
'case_2_feature_b',
|
||||
'alerting_rule_type_one'
|
||||
);
|
||||
const ruleTwoTransformed = await createRule(
|
||||
transformedUser,
|
||||
'case_2_a_transform_two',
|
||||
'case_2_feature_c',
|
||||
'alerting_rule_type_two'
|
||||
);
|
||||
|
||||
// Users with deprecated privileges should be able to access rules created by themselves and
|
||||
// users with new privileges.
|
||||
for (const ruleToCheck of [
|
||||
ruleOneDeprecated,
|
||||
ruleTwoDeprecated,
|
||||
ruleOneTransformed,
|
||||
ruleTwoTransformed,
|
||||
]) {
|
||||
expect(await getRule(deprecatedUser, ruleToCheck.id)).toBeDefined();
|
||||
}
|
||||
|
||||
// NOTE: Scenarios below require SO migrations for both alerting rules and alerts to switch to
|
||||
// a new producer that is tied to feature ID. Presumably we won't have this requirement once
|
||||
// https://github.com/elastic/kibana/pull/183756 is resolved.
|
||||
|
||||
// Create rules as user with new privileges (B).
|
||||
// const newUserB = getUserCredentials('case_2_b_new');
|
||||
// const caseOneNewB = await createRule(newUserB, {
|
||||
// title: 'case_2_b_new_one',
|
||||
// owner: 'cases_owner_one',
|
||||
// });
|
||||
//
|
||||
// // Create cases as user with new privileges (C).
|
||||
// const newUserC = getUserCredentials('case_2_c_new');
|
||||
// const caseTwoNewC = await createRule(newUserC, {
|
||||
// title: 'case_2_c_new_two',
|
||||
// owner: 'cases_owner_two',
|
||||
// });
|
||||
//
|
||||
|
||||
// User B and User C should be able to access cases created by themselves and users with
|
||||
// deprecated and transformed privileges, but only for the specific owner.
|
||||
// for (const caseToCheck of [ruleOneDeprecated, ruleOneTransformed, caseOneNewB]) {
|
||||
// expect(await getRule(newUserB, caseToCheck.id)).toBeDefined();
|
||||
// expect(await getRule(newUserC, caseToCheck.id)).toBeUndefined();
|
||||
// }
|
||||
// for (const caseToCheck of [ruleTwoDeprecated, ruleTwoTransformed, caseTwoNewC]) {
|
||||
// expect(await getRule(newUserC, caseToCheck.id)).toBeDefined();
|
||||
// expect(await getRule(newUserB, caseToCheck.id)).toBeUndefined();
|
||||
// }
|
||||
});
|
||||
});
|
||||
}
|
14
x-pack/test/security_api_integration/tests/features/index.ts
Normal file
14
x-pack/test/security_api_integration/tests/features/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('security APIs - Features', function () {
|
||||
loadTestFile(require.resolve('./deprecated_features'));
|
||||
});
|
||||
}
|
|
@ -5147,6 +5147,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/features-provider-plugin@link:x-pack/test/security_api_integration/plugins/features_provider":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/fec-alerts-test-plugin@link:x-pack/test/functional_execution_context/plugins/alerts":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
@ -6391,6 +6395,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/security-authorization-core-common@link:x-pack/packages/security/authorization_core_common":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/security-authorization-core@link:x-pack/packages/security/authorization_core":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue