[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:
Aleh Zasypkin 2024-10-15 11:12:15 +03:00 committed by GitHub
parent abfed861e6
commit d63d72664d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 3894 additions and 182 deletions

View file

@ -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

View file

@ -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",

View file

@ -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({

View file

@ -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"],

View file

@ -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';

View file

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

View file

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

View file

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

View file

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

View file

@ -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';

View file

@ -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'] },
]);
});
});

View file

@ -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;
}

View file

@ -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",

View file

@ -0,0 +1,3 @@
# @kbn/security-authorization-core-common
Contains core authorization logic (shared between server and browser)

View file

@ -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';

View file

@ -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'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/security-authorization-core-common",
"owner": "@elastic/kibana-security"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/security-authorization-core-common",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -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';

View file

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

View file

@ -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, lets 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
* doesnt 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}`;
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": ["jest", "node", "react"]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": []
}

View file

@ -18,6 +18,8 @@ export type {
RoleRemoteIndexPrivilege,
RoleRemoteClusterPrivilege,
FeaturesPrivileges,
RawKibanaFeaturePrivileges,
RawKibanaPrivileges,
} from './src/authorization';
export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './src/licensing';
export type {

View file

@ -6,6 +6,7 @@
*/
export type { FeaturesPrivileges } from './features_privileges';
export type { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from './raw_kibana_privileges';
export type {
Role,
RoleKibanaPrivilege,

View file

@ -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;

View file

@ -14,6 +14,5 @@
"@kbn/core-user-profile-common",
"@kbn/security-plugin-types-common",
"@kbn/core-security-common",
"@kbn/security-authorization-core"
]
}

View file

@ -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';

View file

@ -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() {

View file

@ -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 =

View file

@ -10,6 +10,7 @@
"@kbn/features-plugin",
"@kbn/security-plugin-types-common",
"@kbn/security-authorization-core",
"@kbn/security-authorization-core-common",
"@kbn/licensing-plugin",
]
}

View file

@ -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[];
};
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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",
},
]
`;

View file

@ -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', () => {

View file

@ -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) =>

View file

@ -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({

View file

@ -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';

View file

@ -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(

View file

@ -135,6 +135,7 @@ export class FeaturesPlugin
}
this.featureRegistry.lockRegistration();
this.featureRegistry.validateFeatures();
this.capabilities = uiCapabilitiesForFeatures(
this.featureRegistry.getAllKibanaFeatures(),

View file

@ -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);

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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';

View file

@ -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 },
});
});
});
});

View file

@ -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) => {

View file

@ -6,7 +6,7 @@
*/
import { PrivilegeSerializer } from './privilege_serializer';
import type { RawKibanaPrivileges } from '../../common/model';
import type { RawKibanaPrivileges } from '../../common';
interface SerializedPrivilege {
application: string;

View file

@ -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 = (

View file

@ -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",
],
},
]
`);
}
});
});

View file

@ -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;
}
}
}

View file

@ -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 ?? [];

View file

@ -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);

View file

@ -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,

View file

@ -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';

View file

@ -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: [],
},
},
}
);
});
});

View file

@ -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,
}),
});
}

View file

@ -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: [],
},
],
},
}
);
});
});

View file

@ -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;

View file

@ -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: [],
},
],
},
});
});

View file

@ -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 =

View file

@ -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;

View file

@ -85,6 +85,7 @@
"@kbn/security-authorization-core",
"@kbn/security-role-management-model",
"@kbn/security-ui-components",
"@kbn/security-authorization-core-common",
],
"exclude": [
"target/**/*",

View file

@ -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';

View file

@ -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",

View file

@ -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',

View file

@ -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',

View 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}`,
],
},
};
}

View file

@ -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"
]
}
}

View file

@ -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'] },
},
});
}

View file

@ -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",
]
}

View file

@ -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();
// }
});
});
}

View 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'));
});
}

View file

@ -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 ""